From 2192253f90ef9c73fbf214ff447984d4ff63e1cd Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 7 Oct 2026 15:52:10 +0700 Subject: [PATCH 1/2] chore(deps): bump rustls to 0.23.45 and fix npm audit advisories Resolves RUSTSEC-2026-0285 (rustls TLS 1.3 handshake) and the devalue, source-map-js and undici advisories that fail the Security workflow. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 8 ++++---- web/package-lock.json | 22 ++++++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab88afb..29ca5ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2044,9 +2044,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -2089,9 +2089,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", diff --git a/web/package-lock.json b/web/package-lock.json index b17ad41..90ebfd1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1556,10 +1556,11 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" }, "node_modules/dom-accessibility-api": { "version": "0.6.3", @@ -2481,10 +2482,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -2800,9 +2802,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { From c2f5cc408ee6f9f55558caf7e021912d4514c47d Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 7 Oct 2026 16:00:31 +0700 Subject: [PATCH 2/2] fix(db): numeric project id in DSNs (Fixes #328) JS SDKs require a numeric project id in the DSN path. Add a per-project seq (migrations for SQLite and Postgres, backfilled), emit DSNs as {key}@{host}/{seq}, and resolve the ingest path segment by seq with a legacy UUID fallback so existing DSNs keep working. extract_dsn_key now handles any scheme. Co-Authored-By: Claude Sonnet 5.5 --- crates/trapfall-core/src/store.rs | 5 ++ .../migrations/postgres/006_project_seq.sql | 19 ++++ crates/trapfall-db/src/common.rs | 87 ++++++++++++++++-- crates/trapfall-db/src/lib.rs | 22 +++++ crates/trapfall-db/src/postgres.rs | 39 +++++--- crates/trapfall-db/src/sqlite.rs | 88 ++++++++++++++++--- crates/trapfall-db/tests/common.rs | 35 ++++++++ crates/trapfall-db/tests/postgres_backend.rs | 1 + crates/trapfall-db/tests/sqlite_backend.rs | 5 ++ .../tests/testcontainers_postgres.rs | 1 + .../migrations/20260701000001_project_seq.sql | 9 ++ crates/trapfalld/src/server.rs | 2 +- crates/trapfalld/tests/integration.rs | 37 ++++++++ 13 files changed, 322 insertions(+), 28 deletions(-) create mode 100644 crates/trapfall-db/migrations/postgres/006_project_seq.sql create mode 100644 crates/trapfalld/migrations/20260701000001_project_seq.sql diff --git a/crates/trapfall-core/src/store.rs b/crates/trapfall-core/src/store.rs index e2f692e..dfd68b8 100644 --- a/crates/trapfall-core/src/store.rs +++ b/crates/trapfall-core/src/store.rs @@ -45,6 +45,11 @@ impl Store { self.db.get_project_by_id(id).await } + /// Resolve an ingest URL project segment: numeric DSN seq first, then legacy UUID. + pub async fn resolve_project(&self, segment: &str) -> Result> { + self.db.resolve_project(segment).await + } + pub async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result> { self.db.get_project_by_dsn_key(sentry_key).await } diff --git a/crates/trapfall-db/migrations/postgres/006_project_seq.sql b/crates/trapfall-db/migrations/postgres/006_project_seq.sql new file mode 100644 index 0000000..68e049b --- /dev/null +++ b/crates/trapfall-db/migrations/postgres/006_project_seq.sql @@ -0,0 +1,19 @@ +-- Numeric per-project sequence used as the DSN project id. +-- Sentry JS SDKs require an all-digit project id in the DSN path (#328). +-- The UUID `id` stays the primary key; `seq` is the public, DSN-facing id. +CREATE SEQUENCE IF NOT EXISTS projects_seq_seq; + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS seq BIGINT; + +-- Backfill existing rows in creation order. Idempotent: only touches NULL rows. +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN SELECT id FROM projects WHERE seq IS NULL ORDER BY created_at, id LOOP + UPDATE projects SET seq = nextval('projects_seq_seq') WHERE id = r.id; + END LOOP; +END +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_seq ON projects(seq); diff --git a/crates/trapfall-db/src/common.rs b/crates/trapfall-db/src/common.rs index 225f69a..ee18fb0 100644 --- a/crates/trapfall-db/src/common.rs +++ b/crates/trapfall-db/src/common.rs @@ -202,15 +202,39 @@ pub fn new_id() -> String { Uuid::new_v4().to_string() } -/// Generate a DSN with the given host and project ID. -pub fn generate_dsn_with(host: &str, project_id: &str) -> String { +/// Generate a DSN with the given host and numeric project sequence. +/// +/// Sentry JS SDKs require the DSN path to be an all-digit project id, so the +/// per-project `seq` (not the internal UUID) is rendered into the DSN. +/// Format: `https://{key}@{host}/{seq}`. +pub fn generate_dsn_with(host: &str, seq: i64) -> String { + generate_dsn_with_scheme("https", host, seq) +} + +/// Like [`generate_dsn_with`] but with an explicit URL scheme (`http`/`https`). +pub fn generate_dsn_with_scheme(scheme: &str, host: &str, seq: i64) -> String { let key = Uuid::new_v4(); - format!("https://{key}@{host}/{project_id}") + format!("{scheme}://{key}@{host}/{seq}") } -/// Extract the DSN key from a full DSN URL. +/// Extract the DSN key from a full DSN URL (any scheme, e.g. `http://` or `https://`). pub fn extract_dsn_key(dsn: &str) -> String { - dsn.split('@').next().unwrap_or("").trim_start_matches("https://").to_string() + let userinfo = dsn.split('@').next().unwrap_or(""); + match userinfo.split_once("://") { + Some((_, key)) => key.to_string(), + None => userinfo.to_string(), + } +} + +/// Parse a path segment as a numeric project sequence (all ASCII digits). +/// +/// Returns `None` for UUIDs and any other non-numeric input so callers can +/// fall back to legacy UUID resolution. +pub fn parse_project_seq(segment: &str) -> Option { + if segment.is_empty() || !segment.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + segment.parse().ok() } /// Extract the host from a DSN URL. @@ -373,3 +397,56 @@ pub struct AttachmentRow { pub disk_path: String, pub created_at: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dsn_uses_numeric_project_id() { + let dsn = generate_dsn_with("trapfall.example.com", 7); + assert!(dsn.starts_with("https://")); + assert!(dsn.ends_with("@trapfall.example.com/7")); + let path = dsn.rsplit('/').next().unwrap(); + assert!(path.bytes().all(|b| b.is_ascii_digit())); + } + + #[test] + fn dsn_with_http_scheme() { + let dsn = generate_dsn_with_scheme("http", "localhost:9090", 1); + assert!(dsn.starts_with("http://")); + assert!(dsn.ends_with("@localhost:9090/1")); + } + + #[test] + fn extract_key_handles_http_and_https() { + assert_eq!(extract_dsn_key("https://abc@h.example/1"), "abc"); + assert_eq!(extract_dsn_key("http://abc@localhost:9090/1"), "abc"); + assert_eq!(extract_dsn_key("abc@h/1"), "abc"); + assert_eq!(extract_dsn_key(""), ""); + } + + #[test] + fn extract_key_roundtrips_generated_dsn() { + for scheme in ["http", "https"] { + let dsn = generate_dsn_with_scheme(scheme, "h:1", 3); + let key = extract_dsn_key(&dsn); + assert!(Uuid::parse_str(&key).is_ok(), "key should be a bare uuid, got {key}"); + } + } + + #[test] + fn extract_host_unchanged_for_numeric_dsn() { + assert_eq!(extract_dsn_host("http://k@localhost:9090/5"), "localhost:9090"); + } + + #[test] + fn parse_seq_numeric_only() { + assert_eq!(parse_project_seq("42"), Some(42)); + assert_eq!(parse_project_seq(""), None); + assert_eq!(parse_project_seq("-1"), None); + assert_eq!(parse_project_seq("2e3c789d-98cc-4cee-b0d1-6ed51cab436b"), None); + assert_eq!(parse_project_seq("12abc"), None); + assert_eq!(parse_project_seq("99999999999999999999999"), None); + } +} diff --git a/crates/trapfall-db/src/lib.rs b/crates/trapfall-db/src/lib.rs index e7d3840..4d4ad51 100644 --- a/crates/trapfall-db/src/lib.rs +++ b/crates/trapfall-db/src/lib.rs @@ -146,6 +146,15 @@ pub async fn run_sqlite_migrations(pool: &sqlx::SqlitePool) -> Result<()> { sqlx::query(include_str!("../../trapfalld/migrations/20260613000001_transactions.sql")).execute(pool).await?; sqlx::query(include_str!("../../trapfalld/migrations/20260627000001_release_health.sql")).execute(pool).await?; sqlx::query(include_str!("../../trapfalld/migrations/20260627000002_attachments.sql")).execute(pool).await?; + // Numeric project seq for DSNs (#328). ALTER TABLE ADD COLUMN is not idempotent in SQLite, + // so guard on pragma_table_info. + let has_seq: bool = sqlx::query_scalar("SELECT COUNT(*) > 0 FROM pragma_table_info('projects') WHERE name = 'seq'") + .fetch_one(pool) + .await + .unwrap_or(false); + if !has_seq { + sqlx::query(include_str!("../../trapfalld/migrations/20260701000001_project_seq.sql")).execute(pool).await?; + } Ok(()) } @@ -169,6 +178,7 @@ pub async fn run_postgres_migrations(pool: &sqlx::PgPool) -> Result<()> { sqlx::query(include_str!("../migrations/postgres/003_transactions.sql")).execute(pool).await?; sqlx::query(include_str!("../migrations/postgres/004_release_health.sql")).execute(pool).await?; sqlx::query(include_str!("../migrations/postgres/005_attachments.sql")).execute(pool).await?; + sqlx::query(include_str!("../migrations/postgres/006_project_seq.sql")).execute(pool).await?; Ok(()) } @@ -188,6 +198,18 @@ pub trait Database: Send + Sync { async fn get_project_by_slug(&self, slug: &str) -> Result>; async fn get_project_by_id(&self, id: &str) -> Result>; async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result>; + /// Look up a project by its numeric DSN sequence. + async fn get_project_by_seq(&self, seq: i64) -> Result>; + /// Resolve the project path segment of an ingest URL: numeric `seq` first, + /// falling back to the legacy UUID id so DSNs issued before #328 keep working. + async fn resolve_project(&self, segment: &str) -> Result> { + if let Some(seq) = crate::common::parse_project_seq(segment) { + if let Some(p) = self.get_project_by_seq(seq).await? { + return Ok(Some(p)); + } + } + self.get_project_by_id(segment).await + } async fn list_projects(&self) -> Result>; async fn rotate_dsn(&self, project_id: &str) -> Result; async fn archive_project(&self, project_id: &str) -> Result<()>; diff --git a/crates/trapfall-db/src/postgres.rs b/crates/trapfall-db/src/postgres.rs index 730912a..c83476f 100644 --- a/crates/trapfall-db/src/postgres.rs +++ b/crates/trapfall-db/src/postgres.rs @@ -53,18 +53,22 @@ impl Database for PostgresBackend { async fn create_project_with_host(&self, slug: &str, name: &str, host: &str) -> Result { let id = new_id(); - let dsn = generate_dsn_with(host, &id); + let seq: i64 = sqlx::query_scalar("SELECT nextval('projects_seq_seq')").fetch_one(&self.pool).await?; + let dsn = generate_dsn_with(host, seq); let dsn_key = extract_dsn_key(&dsn); let now = now_rfc3339(); - sqlx::query("INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at) VALUES ($1, $2, $3, $4, $5, $6)") - .bind(&id) - .bind(slug) - .bind(name) - .bind(&dsn_key) - .bind(&dsn) - .bind(&now) - .execute(&self.pool) - .await?; + sqlx::query( + "INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at, seq) VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(&id) + .bind(slug) + .bind(name) + .bind(&dsn_key) + .bind(&dsn) + .bind(&now) + .bind(seq) + .execute(&self.pool) + .await?; Ok(Project { id, slug: slug.to_string(), name: name.to_string(), dsn, created_at: now, archived_at: None }) } @@ -91,6 +95,17 @@ impl Database for PostgresBackend { Ok(row.map(Into::into)) } + async fn get_project_by_seq(&self, seq: i64) -> Result> { + let row = sqlx::query_as::<_, ProjectRow>( + "SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE seq = $1", + ) + .bind(seq) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(Into::into)) + } + async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result> { let row = sqlx::query_as::<_, ProjectRow>( "SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE dsn_key = $1", @@ -114,7 +129,9 @@ impl Database for PostgresBackend { async fn rotate_dsn(&self, project_id: &str) -> Result { let project = self.get_project_by_id(project_id).await?.ok_or_else(|| anyhow::anyhow!("Project not found"))?; let host = extract_dsn_host(&project.dsn); - let new_dsn = generate_dsn_with(&host, project_id); + let seq: i64 = + sqlx::query_scalar("SELECT seq FROM projects WHERE id = $1").bind(project_id).fetch_one(&self.pool).await?; + let new_dsn = generate_dsn_with(&host, seq); let new_dsn_key = extract_dsn_key(&new_dsn); sqlx::query("UPDATE projects SET dsn = $1, dsn_key = $2 WHERE id = $3") .bind(&new_dsn) diff --git a/crates/trapfall-db/src/sqlite.rs b/crates/trapfall-db/src/sqlite.rs index 3aac435..73a7d06 100644 --- a/crates/trapfall-db/src/sqlite.rs +++ b/crates/trapfall-db/src/sqlite.rs @@ -50,18 +50,23 @@ impl Database for SqliteBackend { async fn create_project_with_host(&self, slug: &str, name: &str, host: &str) -> Result { let id = new_id(); - let dsn = generate_dsn_with(host, &id); + let seq: i64 = + sqlx::query_scalar("SELECT COALESCE(MAX(seq), 0) + 1 FROM projects").fetch_one(&self.pool).await?; + let dsn = generate_dsn_with(host, seq); let dsn_key = extract_dsn_key(&dsn); let now = chrono::Utc::now().to_rfc3339(); - sqlx::query("INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at) VALUES (?, ?, ?, ?, ?, ?)") - .bind(&id) - .bind(slug) - .bind(name) - .bind(&dsn_key) - .bind(&dsn) - .bind(&now) - .execute(&self.pool) - .await?; + sqlx::query( + "INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at, seq) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(slug) + .bind(name) + .bind(&dsn_key) + .bind(&dsn) + .bind(&now) + .bind(seq) + .execute(&self.pool) + .await?; Ok(Project { id, slug: slug.to_string(), name: name.to_string(), dsn, created_at: now, archived_at: None }) } @@ -88,6 +93,17 @@ impl Database for SqliteBackend { Ok(row.map(Into::into)) } + async fn get_project_by_seq(&self, seq: i64) -> Result> { + let row = sqlx::query_as::<_, ProjectRow>( + "SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE seq = ?", + ) + .bind(seq) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(Into::into)) + } + async fn get_project_by_dsn_key(&self, sentry_key: &str) -> Result> { let row = sqlx::query_as::<_, ProjectRow>( "SELECT id, slug, name, dsn, created_at, archived_at FROM projects WHERE dsn_key = ?", @@ -116,7 +132,9 @@ impl Database for SqliteBackend { .nth(1) .map(|s| s.split('/').next().unwrap_or("localhost:9090")) .unwrap_or("localhost:9090"); - let new_dsn = generate_dsn_with(host, project_id); + let seq: i64 = + sqlx::query_scalar("SELECT seq FROM projects WHERE id = ?").bind(project_id).fetch_one(&self.pool).await?; + let new_dsn = generate_dsn_with(host, seq); let new_dsn_key = extract_dsn_key(&new_dsn); sqlx::query("UPDATE projects SET dsn = ?, dsn_key = ? WHERE id = ?") .bind(&new_dsn) @@ -1090,9 +1108,57 @@ mod tests { .execute(&pool) .await .unwrap(); + sqlx::query(include_str!("../../trapfalld/migrations/20260701000001_project_seq.sql")) + .execute(&pool) + .await + .unwrap(); SqliteBackend::new(pool) } + #[tokio::test] + async fn test_seq_backfill_for_legacy_projects() { + let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap(); + // Pre-#328 schema: no seq column. + sqlx::query(include_str!("../../trapfalld/migrations/20260606000001_initial.sql")) + .execute(&pool) + .await + .unwrap(); + for (id, slug) in [("legacy-uuid-a", "a"), ("legacy-uuid-b", "b")] { + sqlx::query("INSERT INTO projects (id, slug, name, dsn_key, dsn, created_at) VALUES (?, ?, ?, 'k', 'https://k@h/x', 'now')") + .bind(id) + .bind(slug) + .bind(slug) + .execute(&pool) + .await + .unwrap(); + } + crate::run_sqlite_migrations(&pool).await.unwrap(); + // Idempotent on restart. + crate::run_sqlite_migrations(&pool).await.unwrap(); + + let db = SqliteBackend::new(pool); + assert_eq!(db.resolve_project("1").await.unwrap().unwrap().id, "legacy-uuid-a"); + assert_eq!(db.resolve_project("2").await.unwrap().unwrap().id, "legacy-uuid-b"); + // Legacy UUID DSNs still resolve. + assert_eq!(db.resolve_project("legacy-uuid-b").await.unwrap().unwrap().slug, "b"); + // New projects continue the sequence and produce numeric DSNs. + let p = db.create_project("c", "C").await.unwrap(); + assert!(p.dsn.ends_with("/3"), "got {}", p.dsn); + // Rotating a backfilled legacy project switches its DSN to the numeric id. + let rotated = db.rotate_dsn("legacy-uuid-a").await.unwrap(); + assert!(rotated.ends_with("/1"), "got {rotated}"); + } + + #[tokio::test] + async fn test_resolve_numeric_and_legacy_uuid() { + let db = open_backend().await; + let p = db.create_project("r", "R").await.unwrap(); + let seq = p.dsn.rsplit('/').next().unwrap(); + assert_eq!(db.resolve_project(seq).await.unwrap().unwrap().id, p.id); + assert_eq!(db.resolve_project(&p.id).await.unwrap().unwrap().id, p.id); + assert!(db.resolve_project("424242").await.unwrap().is_none()); + } + #[tokio::test] async fn test_create_and_lookup_project() { let db = open_backend().await; diff --git a/crates/trapfall-db/tests/common.rs b/crates/trapfall-db/tests/common.rs index b5d09b6..f25982c 100644 --- a/crates/trapfall-db/tests/common.rs +++ b/crates/trapfall-db/tests/common.rs @@ -70,6 +70,40 @@ pub async fn project_crud(db: Arc) { assert!(gone.is_none()); } +// ── Numeric project seq / DSN resolution (#328) ─────────────────────── + +pub async fn project_seq_dsn(db: Arc) { + let a = db.create_project("seq-a", "Seq A").await.unwrap(); + let b = db.create_project("seq-b", "Seq B").await.unwrap(); + + // DSN path is all-digit and distinct per project. + let path = |dsn: &str| dsn.rsplit('/').next().unwrap().to_string(); + let (seq_a, seq_b) = (path(&a.dsn), path(&b.dsn)); + assert!(seq_a.bytes().all(|c| c.is_ascii_digit()), "dsn path must be numeric: {}", a.dsn); + assert!(seq_b.bytes().all(|c| c.is_ascii_digit()), "dsn path must be numeric: {}", b.dsn); + assert_ne!(seq_a, seq_b); + assert!(seq_b.parse::().unwrap() > seq_a.parse::().unwrap(), "seq auto-increments"); + + // Numeric resolution. + let by_seq = db.resolve_project(&seq_a).await.unwrap().unwrap(); + assert_eq!(by_seq.id, a.id); + assert_eq!(db.get_project_by_seq(seq_b.parse().unwrap()).await.unwrap().unwrap().id, b.id); + + // Legacy UUID resolution keeps working. + assert_eq!(db.resolve_project(&a.id).await.unwrap().unwrap().id, a.id); + + // Unknown ids resolve to None. + assert!(db.resolve_project("999999").await.unwrap().is_none()); + assert!(db.resolve_project("not-a-project").await.unwrap().is_none()); + + // Rotation changes the key but keeps the numeric project id. + let rotated = db.rotate_dsn(&a.id).await.unwrap(); + assert_ne!(rotated, a.dsn); + assert_eq!(path(&rotated), seq_a); + let new_key = rotated.split('@').next().unwrap().trim_start_matches("https://"); + assert_eq!(db.get_project_by_dsn_key(new_key).await.unwrap().unwrap().id, a.id); +} + // ── Issue Upsert + Dedup ────────────────────────────────────────────── pub async fn issue_upsert_dedup(db: Arc) { @@ -334,6 +368,7 @@ pub async fn ping(db: Arc) { /// Run all shared tests against the given backend. pub async fn run_all(db: Arc) { project_crud(db.clone()).await; + project_seq_dsn(db.clone()).await; issue_upsert_dedup(db.clone()).await; event_operations(db.clone()).await; auth_and_sessions(db.clone()).await; diff --git a/crates/trapfall-db/tests/postgres_backend.rs b/crates/trapfall-db/tests/postgres_backend.rs index dda1fb0..22d9d02 100644 --- a/crates/trapfall-db/tests/postgres_backend.rs +++ b/crates/trapfall-db/tests/postgres_backend.rs @@ -56,6 +56,7 @@ macro_rules! pg_test { } pg_test!(pg_project_crud, common::project_crud); +pg_test!(pg_project_seq_dsn, common::project_seq_dsn); pg_test!(pg_issue_upsert_dedup, common::issue_upsert_dedup); pg_test!(pg_event_operations, common::event_operations); pg_test!(pg_auth_and_sessions, common::auth_and_sessions); diff --git a/crates/trapfall-db/tests/sqlite_backend.rs b/crates/trapfall-db/tests/sqlite_backend.rs index e1a9c4e..1a6681a 100644 --- a/crates/trapfall-db/tests/sqlite_backend.rs +++ b/crates/trapfall-db/tests/sqlite_backend.rs @@ -20,6 +20,11 @@ async fn sqlite_project_crud() { common::project_crud(setup().await).await; } +#[tokio::test] +async fn sqlite_project_seq_dsn() { + common::project_seq_dsn(setup().await).await; +} + #[tokio::test] async fn sqlite_issue_upsert_dedup() { common::issue_upsert_dedup(setup().await).await; diff --git a/crates/trapfall-db/tests/testcontainers_postgres.rs b/crates/trapfall-db/tests/testcontainers_postgres.rs index fa38e96..e769f3a 100644 --- a/crates/trapfall-db/tests/testcontainers_postgres.rs +++ b/crates/trapfall-db/tests/testcontainers_postgres.rs @@ -73,6 +73,7 @@ macro_rules! tc_test { } tc_test!(tc_project_crud, common::project_crud); +tc_test!(tc_project_seq_dsn, common::project_seq_dsn); tc_test!(tc_issue_upsert_dedup, common::issue_upsert_dedup); tc_test!(tc_event_operations, common::event_operations); tc_test!(tc_auth_and_sessions, common::auth_and_sessions); diff --git a/crates/trapfalld/migrations/20260701000001_project_seq.sql b/crates/trapfalld/migrations/20260701000001_project_seq.sql new file mode 100644 index 0000000..ac322cb --- /dev/null +++ b/crates/trapfalld/migrations/20260701000001_project_seq.sql @@ -0,0 +1,9 @@ +-- Numeric per-project sequence used as the DSN project id. +-- Sentry JS SDKs require an all-digit project id in the DSN path (#328). +-- The UUID `id` stays the primary key; `seq` is the public, DSN-facing id. +ALTER TABLE projects ADD COLUMN seq INTEGER; + +-- Backfill existing rows in creation order (1, 2, 3, ...). +UPDATE projects SET seq = (SELECT COUNT(*) FROM projects p2 WHERE p2.rowid <= projects.rowid); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_seq ON projects(seq); diff --git a/crates/trapfalld/src/server.rs b/crates/trapfalld/src/server.rs index 2dda053..b3a5975 100644 --- a/crates/trapfalld/src/server.rs +++ b/crates/trapfalld/src/server.rs @@ -370,7 +370,7 @@ async fn ingest_envelope( }; // Verify DSN key matches project - let project = match store.get_project_by_id(&project_id).await { + let project = match store.resolve_project(&project_id).await { Ok(Some(p)) => p, Ok(None) => { tracing::warn!("Project not found by id: {project_id}"); diff --git a/crates/trapfalld/tests/integration.rs b/crates/trapfalld/tests/integration.rs index 72543e5..72540a7 100644 --- a/crates/trapfalld/tests/integration.rs +++ b/crates/trapfalld/tests/integration.rs @@ -104,6 +104,43 @@ async fn ingest_accepts_valid_envelope_with_dsn_key() { assert_eq!(resp.status(), StatusCode::OK); } +#[tokio::test] +async fn ingest_accepts_numeric_project_id_from_dsn() { + let store = test_store().await; + let uuid = seed_project(&store).await; + let project = store.get_project_by_id(&uuid).await.unwrap().unwrap(); + let seq = project.dsn.rsplit('/').next().unwrap().to_string(); + assert!(seq.bytes().all(|b| b.is_ascii_digit()), "DSN project id must be numeric: {}", project.dsn); + let app = router(make_state(store, RateLimiter::default())); + + // Numeric (new DSN) path. + let req = Request::builder() + .method("POST") + .uri(format!("/api/{seq}/envelope/")) + .header("authorization", "Bearer abc123") + .body(Body::from(make_envelope_body("TypeError", "numeric"))) + .unwrap(); + assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK); + + // Legacy UUID path still works. + let req = Request::builder() + .method("POST") + .uri(format!("/api/{uuid}/envelope/")) + .header("authorization", "Bearer abc123") + .body(Body::from(make_envelope_body("TypeError", "legacy"))) + .unwrap(); + assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK); + + // Unknown numeric id -> 404. + let req = Request::builder() + .method("POST") + .uri("/api/987654/envelope/") + .header("authorization", "Bearer abc123") + .body(Body::from(make_envelope_body("TypeError", "missing"))) + .unwrap(); + assert_eq!(app.oneshot(req).await.unwrap().status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn ingest_rejects_without_auth() { let store = test_store().await;