From 343522dd156353d8347b06661f680a56d74bbfe9 Mon Sep 17 00:00:00 2001 From: drei Date: Wed, 27 May 2026 19:57:03 -0500 Subject: [PATCH 1/5] Add Id

type + strict ObjectId-confinement architecture rule (#156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual problem with #156 wasn't the hex string format — it's that `mongodb::bson::oid::ObjectId` (a type from the mongo crate) was being passed through service-layer models, transport handlers, webhook payloads, and MCP tool inputs. Every layer that mentioned an ID transitively depended on MongoDB. This PR fixes the architectural smell, not the format: - `core::public_id::Id

` wraps a 24-char ObjectId hex string. Wire format is `_` (e.g. `aff_665a1b2c3d4e5f6a7b8c9d0e`), added on serialize, validated on deserialize. Marker `P` makes per-resource aliases (`AffiliateId`, `TenantId`, …) distinct types at compile time. - `Id::from_object_id(oid)` / `id.to_object_id()?` form the bridge. Repos own these calls; everyone else just passes the typed value. - No new ID format, no data migration, no backfill, no schema change. The underlying value is still the raw ObjectId hex — just with a prefix at the API boundary. Enforcement is structural, not advisory: - `architecture_tests::object_id_confined_to_storage_layer` walks `src/` and bans the word `ObjectId` everywhere except an allowlist: `**/repo.rs`, `migrations/**`, `core/db.rs`, `core/public_id/mod.rs`, `app.rs`, `main.rs`, `*_tests.rs`. New files inherit the rule. - `OBJECT_ID_BACKLOG` lists existing violator files (45 entries); follow-up commits remove them one at a time. - `object_id_backlog_entries_still_have_violations` is the symmetric test: a file on the backlog must still contain `ObjectId`, or the test fails. The backlog can only shrink. Files added/modified: - `core/public_id/mod.rs` — type, trait, all 10 marker types, impls (Serialize/Deserialize, Display, FromStr, Hash, Ord, utoipa `ToSchema` + `PartialSchema`, schemars `JsonSchema` under `mcp`) - `core/public_id/models.rs` — `ParseIdError`, 10 type aliases - `core/public_id/public_id_tests.rs` — 18 unit tests - `core/mod.rs` — register `pub mod public_id` - `architecture_tests.rs` — new test + backlog + 6 parser tests - `CLAUDE.md` — new "Public identifiers" section Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 + server/src/architecture_tests.rs | 315 +++++++++++++++++++ server/src/core/mod.rs | 5 + server/src/core/public_id/mod.rs | 289 +++++++++++++++++ server/src/core/public_id/models.rs | 33 ++ server/src/core/public_id/public_id_tests.rs | 159 ++++++++++ 6 files changed, 812 insertions(+) create mode 100644 server/src/core/public_id/mod.rs create mode 100644 server/src/core/public_id/models.rs create mode 100644 server/src/core/public_id/public_id_tests.rs diff --git a/CLAUDE.md b/CLAUDE.md index 5d31ef7..0d6a2f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,17 @@ The reason: both `api/` and `mcp/` are transport layers that import from `servic - **Auth sub-slices** — `services/auth/` contains `tenants/` (billing entity), `users/` (team members, email verification), `secret_keys/` (signup/verify/CRUD, `rl_live_` keys with `service.rs`), `publishable_keys/` (SDK keys, `pk_live_` prefix), and `usage/` (request tracking). Transport routes live in `api/auth/` +### Public identifiers — `Id

`, never raw `ObjectId` + +`mongodb::bson::oid::ObjectId` is the storage primary key. It lives in **repos and migrations only**. Everywhere else — models, services, route handlers, MCP tools, webhook payloads — uses `core::public_id::Id

` (or a per-resource alias like `AffiliateId`, `TenantId`, etc.). The point is type hygiene: service-layer code should not have a transitive dependency on the MongoDB crate just to talk about IDs. See issue #156. + +- **Wire format**: `_<24-char-lowercase-hex>`, where the body is the raw `ObjectId::to_hex()`. No new ID format, no data migration. Example: `aff_665a1b2c3d4e5f6a7b8c9d0e`. +- **Bridge**: `Id::from_object_id(oid)` (repo → typed) and `id.to_object_id()?` (typed → repo). Both are infallible in practice; construction validates the hex body. +- **Adding a new resource alias**: declare a marker in `core/public_id/mod.rs` (`crate::impl_container!(FooIdMarker);`, then `impl IdPrefix for FooIdMarker { const PREFIX = "foo"; const SCHEMA_NAME = "FooId"; }`) and add `pub type FooId = Id;` to `core/public_id/models.rs`. +- **Typed at compile time**: `AffiliateId` and `WebhookId` are distinct types. Passing one where the other is expected fails to build. +- **Enforced** by `architecture_tests::object_id_confined_to_storage_layer`. Allowlisted file patterns: `**/repo.rs`, `migrations/**`, `core/db.rs`, `core/public_id/mod.rs`, `app.rs`, `main.rs`, `*_tests.rs`. Pre-existing violators live in `OBJECT_ID_BACKLOG`; the symmetric `object_id_backlog_entries_still_have_violations` test fails if a listed file no longer contains `ObjectId`, so the backlog can only shrink. +- **Exception**: `link_id` remains a custom vanity slug (product feature, not random), not an `Id

`. + ### Cargo Features - `api` — HTTP API routes (enabled by default) diff --git a/server/src/architecture_tests.rs b/server/src/architecture_tests.rs index c7edf08..2376ec2 100644 --- a/server/src/architecture_tests.rs +++ b/server/src/architecture_tests.rs @@ -148,6 +148,89 @@ fn stepdown_rule_at_depth_zero() { } } +/// Enforce that `ObjectId` (the MongoDB storage type) only appears in the +/// storage layer. Anywhere else uses `core::public_id::Id

` instead. +/// +/// **Allowlist** (files that may reference `ObjectId`): +/// - `src/services/**/repo.rs` — repos own storage +/// - `src/migrations/**.rs` — direct BSON manipulation +/// - `src/core/db.rs` — connection wiring +/// - `src/core/public_id/mod.rs` — the bridge type (`Id::from_object_id` / `to_object_id`) +/// - `src/app.rs`, `src/main.rs` — bootstrap +/// - `*_tests.rs` — sibling tests may reference any type +/// +/// Existing violators are listed in `OBJECT_ID_BACKLOG`; each migration commit +/// removes its entry. The backlog can only shrink — the symmetric test +/// `object_id_backlog_entries_still_have_violations` fails if a listed file +/// no longer contains `ObjectId`. See issue #156. +#[test] +fn object_id_confined_to_storage_layer() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + let src = std::path::Path::new(&manifest_dir).join("src"); + + let mut violations: Vec = Vec::new(); + scan_for_object_id(&src, &manifest_dir, &mut violations); + + if !violations.is_empty() { + panic!( + "\nFound {} reference(s) to `ObjectId` outside the storage layer allowlist:\n\n{}\n\n\ + Per CLAUDE.md \"Public identifiers\": `ObjectId` lives only in repos and migrations.\n\ + Use `core::public_id::Id

` (or a per-resource alias like `AffiliateId`)\n\ + everywhere else. Convert at the repo boundary with `Id::from_object_id` /\n\ + `id.to_object_id()`. See issue #156.\n", + violations.len(), + violations.join("\n") + ); + } +} + +/// Symmetric check on [`OBJECT_ID_BACKLOG`]: every entry must reference a file +/// that still contains `ObjectId`. Migrated files have to be removed from the +/// list so the rule starts biting on them. Also fails on entries that reference +/// files no longer on disk (stale). +#[test] +fn object_id_backlog_entries_still_have_violations() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + let mut stale: Vec<&str> = Vec::new(); + let mut clean: Vec<&str> = Vec::new(); + for entry in OBJECT_ID_BACKLOG { + let abs = std::path::Path::new(&manifest_dir).join(entry); + let Ok(content) = std::fs::read_to_string(&abs) else { + stale.push(entry); + continue; + }; + if !file_mentions_object_id(&content) { + clean.push(entry); + } + } + + let mut messages: Vec = Vec::new(); + if !stale.is_empty() { + messages.push(format!( + "Backlog references files that no longer exist:\n{}", + stale + .iter() + .map(|p| format!(" - {p}")) + .collect::>() + .join("\n") + )); + } + if !clean.is_empty() { + messages.push(format!( + "Backlog references files that no longer mention `ObjectId`\n\ + — remove them from `OBJECT_ID_BACKLOG`:\n{}", + clean + .iter() + .map(|p| format!(" - {p}")) + .collect::>() + .join("\n") + )); + } + if !messages.is_empty() { + panic!("\n{}\n", messages.join("\n\n")); + } +} + /// Files where the architecture rules must NOT be violated. /// /// **Denylist**: every `.rs` under `src/` is enforced *except* those @@ -218,6 +301,60 @@ const PUB_TYPES_CLEANUP_BACKLOG: &[&str] = &[]; /// primitives, session lookup, public OAuth start/callback, etc.). const AUTH_MIGRATION_BACKLOG: &[&str] = &[]; +/// Files that currently reference `ObjectId` outside the storage allowlist +/// and have not yet been migrated to `core::public_id::Id

`. Each migration +/// commit removes one entry; this list will reach empty when the cutover is +/// done. See issue #156. +/// +/// New files inherit enforcement — do not add entries here. +const OBJECT_ID_BACKLOG: &[&str] = &[ + "src/api/affiliates/routes.rs", + "src/api/apps/routes.rs", + "src/api/auth/middleware.rs", + "src/api/auth/models.rs", + "src/api/auth/publishable_keys/routes.rs", + "src/api/auth/secret_keys/routes.rs", + "src/api/auth/users/routes.rs", + "src/api/billing/stripe_webhook.rs", + "src/api/conversions/routes.rs", + "src/api/lifecycle/routes.rs", + "src/api/links/routes.rs", + "src/api/webhooks/routes.rs", + "src/services/affiliates/models.rs", + "src/services/affiliates/service.rs", + "src/services/analytics/service.rs", + "src/services/app_users/models.rs", + "src/services/apps/models.rs", + "src/services/auth/oauth/models.rs", + "src/services/auth/oauth/service.rs", + "src/services/auth/permissions/context.rs", + "src/services/auth/permissions/models.rs", + "src/services/auth/publishable_keys/models.rs", + "src/services/auth/secret_keys/models.rs", + "src/services/auth/secret_keys/service.rs", + "src/services/auth/sessions/models.rs", + "src/services/auth/sessions/service.rs", + "src/services/auth/tenants/models.rs", + "src/services/auth/tenants/service.rs", + "src/services/auth/usage/models.rs", + "src/services/auth/users/models.rs", + "src/services/auth/users/service.rs", + "src/services/billing/models.rs", + "src/services/billing/quota.rs", + "src/services/billing/repos/event_counters.rs", + "src/services/billing/repos/resource_counts_adapter.rs", + "src/services/billing/service.rs", + "src/services/conversions/models.rs", + "src/services/conversions/service.rs", + "src/services/domains/models.rs", + "src/services/install_events/models.rs", + "src/services/links/models.rs", + "src/services/links/service.rs", + "src/services/webhooks/dispatcher.rs", + "src/services/webhooks/models.rs", + "src/services/webhooks/service.rs", +]; + /// Whether `path` is on the cleanup backlog (suppress pub-types check only). fn is_cleanup_backlog(path: &std::path::Path) -> bool { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); @@ -612,6 +749,120 @@ fn has_requires_attribute_above(lines: &[&str], fn_line: usize) -> bool { false } +// ── ObjectId-confinement scanner ── + +/// Files allowed to import / reference `ObjectId`. Anything else triggers +/// `object_id_confined_to_storage_layer` (unless on `OBJECT_ID_BACKLOG`). +const OBJECT_ID_ALLOWED_FILES: &[&str] = &[ + "src/app.rs", + "src/main.rs", + "src/core/db.rs", + "src/core/public_id/mod.rs", +]; + +fn is_object_id_allowed(rel_str: &str) -> bool { + // Allowlisted exact paths. + if OBJECT_ID_ALLOWED_FILES.contains(&rel_str) { + return true; + } + // Repos and migrations own storage. + if rel_str.starts_with("src/migrations/") { + return true; + } + if rel_str.ends_with("/repo.rs") { + return true; + } + // Repos with sub-directories like `services/billing/repos/foo.rs` are repo files too. + if rel_str.contains("/repos/") { + return true; + } + // Sibling test files may reference any type. + if let Some(name) = std::path::Path::new(rel_str) + .file_name() + .and_then(|s| s.to_str()) + { + if name.ends_with("_tests.rs") { + return true; + } + } + false +} + +fn is_object_id_backlog(rel_str: &str) -> bool { + OBJECT_ID_BACKLOG.contains(&rel_str) +} + +/// Walk `dir` recursively. For every `.rs` file not on the allowlist and not +/// on the backlog, record any `ObjectId` (whole-word) reference. +fn scan_for_object_id(dir: &std::path::Path, manifest_dir: &str, violations: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.is_dir() { + scan_for_object_id(&path, manifest_dir, violations); + continue; + } + if path.extension().and_then(|s| s.to_str()) != Some("rs") { + continue; + } + let rel = path.strip_prefix(manifest_dir).unwrap_or(&path); + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if is_object_id_allowed(&rel_str) || is_object_id_backlog(&rel_str) { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + for (line_num, line) in content.lines().enumerate() { + // Strip line comments so we don't trip on doc-comments mentioning the type. + let code = strip_line_comment(line); + if contains_object_id_word(code) { + violations.push(format!(" {}:{}", rel_str, line_num + 1)); + } + } + } +} + +fn file_mentions_object_id(content: &str) -> bool { + content + .lines() + .any(|line| contains_object_id_word(strip_line_comment(line))) +} + +/// Whole-word match for `ObjectId`. Rejects substrings (e.g. `MyObjectIdRef`). +fn contains_object_id_word(s: &str) -> bool { + let needle = "ObjectId"; + let bytes = s.as_bytes(); + let nb = needle.as_bytes(); + let mut i = 0; + while i + nb.len() <= bytes.len() { + if &bytes[i..i + nb.len()] == nb { + let prev_ok = + i == 0 || !matches!(bytes[i - 1], b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_'); + let next = i + nb.len(); + let next_ok = next == bytes.len() + || !matches!(bytes[next], b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_'); + if prev_ok && next_ok { + return true; + } + } + i += 1; + } + false +} + +/// Strip `//`-line comments. Naive: doesn't handle `//` inside string literals. +/// Acceptable for this codebase — no source line has `//` inside a string before +/// a meaningful `ObjectId` reference. +fn strip_line_comment(line: &str) -> &str { + match line.find("//") { + Some(i) => &line[..i], + None => line, + } +} + #[cfg(test)] mod parser_tests { use super::parse_pub_type_name; @@ -729,6 +980,70 @@ mod parser_tests { assert_eq!(classify_free_fn("let x = 1;"), None); } + use super::{contains_object_id_word, is_object_id_allowed, strip_line_comment}; + + #[test] + fn word_match_accepts_standalone() { + assert!(contains_object_id_word(" pub id: ObjectId,")); + assert!(contains_object_id_word("fn foo(id: ObjectId) {")); + assert!(contains_object_id_word( + "pub tenant_id: mongodb::bson::oid::ObjectId," + )); + assert!(contains_object_id_word("Vec")); + assert!(contains_object_id_word("Option")); + assert!(contains_object_id_word("ObjectId::parse_str(s)")); + } + + #[test] + fn word_match_rejects_substring() { + assert!(!contains_object_id_word("MyObjectIdRef")); + assert!(!contains_object_id_word("ObjectIds")); + assert!(!contains_object_id_word("_ObjectId")); + assert!(!contains_object_id_word("foo123ObjectId")); + } + + #[test] + fn strip_line_comment_works() { + assert_eq!( + strip_line_comment("pub x: i32, // ObjectId here"), + "pub x: i32, " + ); + assert_eq!(strip_line_comment("// just a doc ObjectId"), ""); + assert_eq!(strip_line_comment("pub id: ObjectId,"), "pub id: ObjectId,"); + } + + #[test] + fn allowlist_includes_repos() { + assert!(is_object_id_allowed("src/services/affiliates/repo.rs")); + assert!(is_object_id_allowed("src/services/auth/users/repo.rs")); + assert!(is_object_id_allowed( + "src/services/billing/repos/event_counters.rs" + )); + } + + #[test] + fn allowlist_includes_migrations_and_bootstrap() { + assert!(is_object_id_allowed("src/migrations/m001_auth_split.rs")); + assert!(is_object_id_allowed("src/core/db.rs")); + assert!(is_object_id_allowed("src/core/public_id/mod.rs")); + assert!(is_object_id_allowed("src/app.rs")); + assert!(is_object_id_allowed("src/main.rs")); + } + + #[test] + fn allowlist_includes_sibling_tests() { + assert!(is_object_id_allowed("src/services/billing/quota_tests.rs")); + assert!(is_object_id_allowed("src/services/links/service_tests.rs")); + } + + #[test] + fn allowlist_excludes_transports_and_services() { + assert!(!is_object_id_allowed("src/api/affiliates/routes.rs")); + assert!(!is_object_id_allowed("src/services/affiliates/models.rs")); + assert!(!is_object_id_allowed("src/services/affiliates/service.rs")); + assert!(!is_object_id_allowed("src/mcp/models.rs")); + } + #[test] fn fn_classify_extern() { // `extern "C" fn foo()` and `pub extern "C" fn foo()` diff --git a/server/src/core/mod.rs b/server/src/core/mod.rs index 9a45f25..a2446bb 100644 --- a/server/src/core/mod.rs +++ b/server/src/core/mod.rs @@ -5,6 +5,11 @@ pub mod email; pub mod http; pub mod models; pub mod origin; +// Phase 1 foundation for issue #156. No consumers yet — follow-up commits wire +// each resource. The bin target compiles this module without any reaching +// reference from `main.rs`, hence the blanket dead_code allow. +#[allow(dead_code, unused_imports)] +pub mod public_id; pub mod rate_limit; pub mod threat_feed; pub mod validation; diff --git a/server/src/core/public_id/mod.rs b/server/src/core/public_id/mod.rs new file mode 100644 index 0000000..281e8e7 --- /dev/null +++ b/server/src/core/public_id/mod.rs @@ -0,0 +1,289 @@ +//! Typed prefixed identifiers — the only ID type allowed outside the storage layer. +//! +//! The wire format is `_<24-char-lowercase-hex>` where the body is the raw +//! MongoDB `ObjectId` hex. `Id

` stores just the hex; the prefix is added on +//! serialize and validated on deserialize. Marker `P` makes per-resource aliases +//! distinct types — passing an `AffiliateId` where a `WebhookId` is expected fails +//! to build. +//! +//! Background: issue #156. `ObjectId` is the MongoDB storage type and must not +//! appear anywhere except repos and migrations. Architecture test +//! `object_id_confined_to_storage_layer` enforces this; new files inherit the rule. + +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; + +use mongodb::bson::oid::ObjectId; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +pub mod models; +pub use models::{ + AffiliateId, AppId, ConversionEventId, DomainId, ParseIdError, PublishableKeyId, SecretKeyId, + SourceId, TenantId, UserId, WebhookId, +}; + +/// Implemented by zero-sized marker types to declare a resource's prefix and schema name. +pub trait IdPrefix { + /// Wire-format prefix, e.g. `"aff"` → `"aff_665a…"`. + const PREFIX: &'static str; + /// Name surfaced in OpenAPI / JSON Schema documents, e.g. `"AffiliateId"`. + const SCHEMA_NAME: &'static str; +} + +/// 24-char lowercase ObjectId hex length. ObjectIds are always 12 bytes → 24 hex chars. +pub const HEX_LEN: usize = 24; + +crate::impl_container!(Id); +/// Typed prefixed identifier wrapping a raw ObjectId hex string. +/// +/// Construction goes through `from_object_id` (from a repo) or `parse` (from the wire); +/// both validate the body is exactly 24 lowercase hex chars. Serializes as +/// `_`; deserializes by checking the prefix matches `P::PREFIX`. +pub struct Id { + /// The raw 24-char ObjectId hex. **No prefix.** + hex: String, + _marker: PhantomData P>, +} + +impl Id

{ + /// Construct from a MongoDB ObjectId. Repo layer only. + pub fn from_object_id(oid: ObjectId) -> Self { + Self { + hex: oid.to_hex(), + _marker: PhantomData, + } + } + + /// Convert back to an `ObjectId` for storage queries. Repo layer only. + /// Infallible in practice because construction validates the hex body — + /// returns `Err` only if the underlying hex was somehow corrupted. + pub fn to_object_id(&self) -> Result { + ObjectId::parse_str(&self.hex).map_err(|_| ParseIdError::InvalidHex) + } + + /// Parse `_<24-char-hex>`. The body must be lowercase hex (matching + /// `ObjectId::to_hex()`). + pub fn parse(s: &str) -> Result { + let (prefix, body) = s.split_once('_').ok_or(ParseIdError::MissingSeparator)?; + if prefix != P::PREFIX { + return Err(ParseIdError::WrongPrefix { + expected: P::PREFIX, + got: prefix.to_string(), + }); + } + if body.len() != HEX_LEN { + return Err(ParseIdError::InvalidLength { + expected: HEX_LEN, + got: body.len(), + }); + } + if !body.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return Err(ParseIdError::InvalidHex); + } + Ok(Self { + hex: body.to_string(), + _marker: PhantomData, + }) + } + + /// Borrow the raw hex body (no prefix). Repo layer use. + pub fn as_hex(&self) -> &str { + &self.hex + } +} + +impl From for Id

{ + fn from(oid: ObjectId) -> Self { + Self::from_object_id(oid) + } +} + +impl Clone for Id

{ + fn clone(&self) -> Self { + Self { + hex: self.hex.clone(), + _marker: PhantomData, + } + } +} + +impl PartialEq for Id

{ + fn eq(&self, other: &Self) -> bool { + self.hex == other.hex + } +} +impl Eq for Id

{} + +impl std::hash::Hash for Id

{ + fn hash(&self, state: &mut H) { + self.hex.hash(state); + } +} + +impl PartialOrd for Id

{ + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Id

{ + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.hex.cmp(&other.hex) + } +} + +impl fmt::Display for Id

{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}_{}", P::PREFIX, self.hex) + } +} + +impl fmt::Debug for Id

{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "\"{}_{}\"", P::PREFIX, self.hex) + } +} + +impl FromStr for Id

{ + type Err = ParseIdError; + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl Serialize for Id

{ + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(&format_args!("{}_{}", P::PREFIX, self.hex)) + } +} + +impl<'de, P: IdPrefix> Deserialize<'de> for Id

{ + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl utoipa::PartialSchema for Id

{ + fn schema() -> utoipa::openapi::RefOr { + use utoipa::openapi::schema::{Object, SchemaType, Type}; + Object::builder() + .schema_type(SchemaType::Type(Type::String)) + .pattern(Some(format!("^{}_[0-9a-f]{{{}}}$", P::PREFIX, HEX_LEN))) + .examples([serde_json::Value::String(format!( + "{}_{}", + P::PREFIX, + "0".repeat(HEX_LEN) + ))]) + .description(Some(format!( + "Prefixed public identifier (prefix `{}_`, 24-char lowercase ObjectId hex body).", + P::PREFIX, + ))) + .into() + } +} + +impl utoipa::ToSchema for Id

{ + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(P::SCHEMA_NAME) + } +} + +#[cfg(feature = "mcp")] +impl schemars::JsonSchema for Id

{ + fn inline_schema() -> bool { + true + } + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(P::SCHEMA_NAME) + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "pattern": format!("^{}_[0-9a-f]{{{}}}$", P::PREFIX, HEX_LEN), + "description": format!( + "Prefixed public identifier (prefix `{}_`, 24-char lowercase ObjectId hex body).", + P::PREFIX, + ), + }) + } +} + +// ── Per-resource marker types ── +// +// One marker per public-facing resource. Each marker is a zero-sized type whose +// sole purpose is hosting `impl IdPrefix` — hence the `impl_container!` exemption. + +crate::impl_container!(AffiliateIdMarker); +pub struct AffiliateIdMarker; +impl IdPrefix for AffiliateIdMarker { + const PREFIX: &'static str = "aff"; + const SCHEMA_NAME: &'static str = "AffiliateId"; +} + +crate::impl_container!(AppIdMarker); +pub struct AppIdMarker; +impl IdPrefix for AppIdMarker { + const PREFIX: &'static str = "app"; + const SCHEMA_NAME: &'static str = "AppId"; +} + +crate::impl_container!(ConversionEventIdMarker); +pub struct ConversionEventIdMarker; +impl IdPrefix for ConversionEventIdMarker { + const PREFIX: &'static str = "cev"; + const SCHEMA_NAME: &'static str = "ConversionEventId"; +} + +crate::impl_container!(DomainIdMarker); +pub struct DomainIdMarker; +impl IdPrefix for DomainIdMarker { + const PREFIX: &'static str = "dom"; + const SCHEMA_NAME: &'static str = "DomainId"; +} + +crate::impl_container!(PublishableKeyIdMarker); +pub struct PublishableKeyIdMarker; +impl IdPrefix for PublishableKeyIdMarker { + const PREFIX: &'static str = "pkid"; + const SCHEMA_NAME: &'static str = "PublishableKeyId"; +} + +crate::impl_container!(SecretKeyIdMarker); +pub struct SecretKeyIdMarker; +impl IdPrefix for SecretKeyIdMarker { + const PREFIX: &'static str = "sk"; + const SCHEMA_NAME: &'static str = "SecretKeyId"; +} + +crate::impl_container!(SourceIdMarker); +pub struct SourceIdMarker; +impl IdPrefix for SourceIdMarker { + const PREFIX: &'static str = "src"; + const SCHEMA_NAME: &'static str = "SourceId"; +} + +crate::impl_container!(TenantIdMarker); +pub struct TenantIdMarker; +impl IdPrefix for TenantIdMarker { + const PREFIX: &'static str = "tnt"; + const SCHEMA_NAME: &'static str = "TenantId"; +} + +crate::impl_container!(UserIdMarker); +pub struct UserIdMarker; +impl IdPrefix for UserIdMarker { + const PREFIX: &'static str = "usr"; + const SCHEMA_NAME: &'static str = "UserId"; +} + +crate::impl_container!(WebhookIdMarker); +pub struct WebhookIdMarker; +impl IdPrefix for WebhookIdMarker { + const PREFIX: &'static str = "wh"; + const SCHEMA_NAME: &'static str = "WebhookId"; +} + +#[cfg(test)] +#[path = "public_id_tests.rs"] +mod tests; diff --git a/server/src/core/public_id/models.rs b/server/src/core/public_id/models.rs new file mode 100644 index 0000000..bc37c3c --- /dev/null +++ b/server/src/core/public_id/models.rs @@ -0,0 +1,33 @@ +//! Data types for `core::public_id` — the parse error enum and per-resource +//! type aliases. The `Id

` struct itself lives in `mod.rs` because it +//! hosts every trait impl in this module. + +use super::{ + AffiliateIdMarker, AppIdMarker, ConversionEventIdMarker, DomainIdMarker, Id, + PublishableKeyIdMarker, SecretKeyIdMarker, SourceIdMarker, TenantIdMarker, UserIdMarker, + WebhookIdMarker, +}; + +/// Errors returned by [`Id::parse`] and [`Id::to_object_id`]. +#[derive(Debug, thiserror::Error)] +pub enum ParseIdError { + #[error("missing `_` separator between prefix and body")] + MissingSeparator, + #[error("wrong prefix: expected `{expected}`, got `{got}`")] + WrongPrefix { expected: &'static str, got: String }, + #[error("invalid body length: expected {expected} chars, got {got}")] + InvalidLength { expected: usize, got: usize }, + #[error("body is not valid 24-char lowercase hex")] + InvalidHex, +} + +pub type AffiliateId = Id; +pub type AppId = Id; +pub type ConversionEventId = Id; +pub type DomainId = Id; +pub type PublishableKeyId = Id; +pub type SecretKeyId = Id; +pub type SourceId = Id; +pub type TenantId = Id; +pub type UserId = Id; +pub type WebhookId = Id; diff --git a/server/src/core/public_id/public_id_tests.rs b/server/src/core/public_id/public_id_tests.rs new file mode 100644 index 0000000..3d516d7 --- /dev/null +++ b/server/src/core/public_id/public_id_tests.rs @@ -0,0 +1,159 @@ +use mongodb::bson::oid::ObjectId; + +use super::{AffiliateId, Id, ParseIdError, SourceId, TenantId, WebhookId, HEX_LEN}; + +#[test] +fn from_object_id_stores_hex() { + let oid = ObjectId::new(); + let id: AffiliateId = AffiliateId::from_object_id(oid); + assert_eq!(id.as_hex(), &oid.to_hex()); + assert_eq!(id.as_hex().len(), HEX_LEN); +} + +#[test] +fn display_includes_prefix() { + let oid = ObjectId::new(); + let id: AffiliateId = oid.into(); + assert_eq!(format!("{id}"), format!("aff_{}", oid.to_hex())); +} + +#[test] +fn round_trip_to_object_id() { + let oid = ObjectId::new(); + let id: WebhookId = oid.into(); + let back = id.to_object_id().unwrap(); + assert_eq!(oid, back); +} + +#[test] +fn serialize_to_prefixed_string() { + let oid = ObjectId::parse_str("665a1b2c3d4e5f6a7b8c9d0e").unwrap(); + let id: AffiliateId = oid.into(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"aff_665a1b2c3d4e5f6a7b8c9d0e\""); +} + +#[test] +fn deserialize_from_prefixed_string() { + let json = "\"aff_665a1b2c3d4e5f6a7b8c9d0e\""; + let id: AffiliateId = serde_json::from_str(json).unwrap(); + assert_eq!(id.as_hex(), "665a1b2c3d4e5f6a7b8c9d0e"); +} + +#[test] +fn deserialize_rejects_wrong_prefix() { + let json = "\"aff_665a1b2c3d4e5f6a7b8c9d0e\""; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.to_string().contains("wrong prefix"), "got: {err}"); +} + +#[test] +fn deserialize_rejects_raw_hex_without_prefix() { + let json = "\"665a1b2c3d4e5f6a7b8c9d0e\""; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.to_string().contains("missing"), "got: {err}"); +} + +#[test] +fn deserialize_rejects_uppercase_hex() { + // ObjectId::to_hex() always produces lowercase; the wire format must match. + let json = "\"aff_665A1B2C3D4E5F6A7B8C9D0E\""; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.to_string().contains("hex"), "got: {err}"); +} + +#[test] +fn parse_rejects_wrong_length() { + let err = AffiliateId::parse("aff_665a1b2c3d4e").unwrap_err(); + assert!(matches!( + err, + ParseIdError::InvalidLength { + expected: 24, + got: 12 + } + )); +} + +#[test] +fn parse_rejects_non_hex_body() { + let err = AffiliateId::parse("aff_zzzzzzzzzzzzzzzzzzzzzzzz").unwrap_err(); + assert!(matches!(err, ParseIdError::InvalidHex)); +} + +#[test] +fn parse_rejects_missing_separator() { + let err = AffiliateId::parse("aff665a1b2c3d4e5f6a7b8c9d0e").unwrap_err(); + assert!(matches!(err, ParseIdError::MissingSeparator)); +} + +#[test] +fn fromstr_works() { + let oid = ObjectId::new(); + let s = format!("tnt_{}", oid.to_hex()); + let id: TenantId = s.parse().unwrap(); + assert_eq!(id.as_hex(), &oid.to_hex()); +} + +#[test] +fn distinct_marker_types_have_distinct_schema_names() { + use utoipa::ToSchema; + assert_eq!(AffiliateId::name(), "AffiliateId"); + assert_eq!(TenantId::name(), "TenantId"); + assert_eq!(SourceId::name(), "SourceId"); + assert_eq!(WebhookId::name(), "WebhookId"); +} + +#[test] +fn utoipa_schema_has_lowercase_hex_pattern() { + use utoipa::PartialSchema; + let schema = AffiliateId::schema(); + let json = serde_json::to_value(&schema).unwrap(); + assert_eq!(json["type"], "string"); + assert_eq!(json["pattern"], "^aff_[0-9a-f]{24}$"); +} + +#[cfg(feature = "mcp")] +#[test] +fn schemars_schema_has_lowercase_hex_pattern() { + use schemars::JsonSchema; + let mut gen = schemars::SchemaGenerator::default(); + let schema = AffiliateId::json_schema(&mut gen); + let json = serde_json::to_value(&schema).unwrap(); + assert_eq!(json["type"], "string"); + assert_eq!(json["pattern"], "^aff_[0-9a-f]{24}$"); +} + +#[test] +fn equality_within_same_type() { + let oid = ObjectId::new(); + let a: AffiliateId = oid.into(); + let b: AffiliateId = oid.into(); + assert_eq!(a, b); +} + +#[test] +fn ord_matches_hex_ord() { + let mut ids: Vec = (0..5).map(|_| AffiliateId::from(ObjectId::new())).collect(); + let mut hexes: Vec = ids.iter().map(|i| i.as_hex().to_string()).collect(); + ids.sort(); + hexes.sort(); + let after: Vec = ids.iter().map(|i| i.as_hex().to_string()).collect(); + assert_eq!(after, hexes); +} + +#[test] +fn hash_consistency() { + use std::collections::HashSet; + let oid = ObjectId::new(); + let id: AffiliateId = oid.into(); + let clone = id.clone(); + let mut set = HashSet::new(); + set.insert(id); + assert!(set.contains(&clone)); +} + +// Compile-time: distinct marker types are not interchangeable. +// fn _no_cross_assignment() { +// let a: AffiliateId = ObjectId::new().into(); +// let _w: WebhookId = a; // ERROR +// } From 11f16d9014a177f3c150b700625f94de5a869523 Mon Sep 17 00:00:00 2001 From: drei Date: Thu, 28 May 2026 14:32:24 -0500 Subject: [PATCH 2/5] Id

: format-conditional serde so one struct serves both BSON and JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole reason we were planning a Doc/Response split per resource: BSON ObjectId can't deserialize into a string-backed type, and our `Id

` is string-backed. Without this commit, every `models.rs` would need a private storage struct in repo.rs plus a conversion helper. With this commit, `Id

` branches on `Serializer::is_human_readable()`: - JSON / OpenAPI / MCP wire format: prefixed string `aff_` - BSON (raw, non-human-readable — the path the mongodb driver uses): native ObjectId binary So a struct `Affiliate { id: AffiliateId, tenant_id: TenantId, name: String }` serializes correctly to both MongoDB (BSON ObjectIds for `_id` and `tenant_id`) and to HTTP responses (prefixed strings). No Doc/Response split, no conversion helpers, no per-resource refactor beyond changing field types. Per-resource migration now collapses to mechanical search/replace: - `id: ObjectId` → `id: AffiliateId` (per type) - `&ObjectId` parameters → `&AffiliateId` - `Path` + manual parse → `Path` - `ObjectId::new()` → `AffiliateId::new()` Note on `bson::to_bson` vs the driver path: bson 2.x's `to_bson` defaults to human_readable=true (returns a string for our type). The actual mongodb driver uses `to_raw_document_buf` internally, which is non-human-readable and produces ObjectId binary. The tests exercise the raw path. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/core/public_id/mod.rs | 30 +++++- server/src/core/public_id/public_id_tests.rs | 98 ++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/server/src/core/public_id/mod.rs b/server/src/core/public_id/mod.rs index 281e8e7..1ee390e 100644 --- a/server/src/core/public_id/mod.rs +++ b/server/src/core/public_id/mod.rs @@ -47,6 +47,13 @@ pub struct Id { } impl Id

{ + /// Generate a fresh `Id` backed by a new `ObjectId`. Use this when creating + /// a new resource that needs an ID assigned in application code (i.e. not + /// letting MongoDB generate `_id` on insert). + pub fn new() -> Self { + Self::from_object_id(ObjectId::new()) + } + /// Construct from a MongoDB ObjectId. Repo layer only. pub fn from_object_id(oid: ObjectId) -> Self { Self { @@ -153,14 +160,31 @@ impl FromStr for Id

{ impl Serialize for Id

{ fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(&format_args!("{}_{}", P::PREFIX, self.hex)) + if serializer.is_human_readable() { + // JSON / OpenAPI / MCP wire format: prefixed string. + serializer.collect_str(&format_args!("{}_{}", P::PREFIX, self.hex)) + } else { + // BSON (and any other binary format that opts out of human-readable): + // serialize as a native ObjectId so MongoDB stores `_id` in its + // canonical form. This is the bridge that lets a single struct serve + // both as a BSON document and an HTTP response. + let oid = ObjectId::parse_str(&self.hex).map_err(serde::ser::Error::custom)?; + oid.serialize(serializer) + } } } impl<'de, P: IdPrefix> Deserialize<'de> for Id

{ fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Self::parse(&s).map_err(serde::de::Error::custom) + if deserializer.is_human_readable() { + // JSON / path params / MCP inputs: prefixed string. + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } else { + // BSON: native ObjectId. + let oid = ObjectId::deserialize(deserializer)?; + Ok(Self::from_object_id(oid)) + } } } diff --git a/server/src/core/public_id/public_id_tests.rs b/server/src/core/public_id/public_id_tests.rs index 3d516d7..c7a2def 100644 --- a/server/src/core/public_id/public_id_tests.rs +++ b/server/src/core/public_id/public_id_tests.rs @@ -152,6 +152,104 @@ fn hash_consistency() { assert!(set.contains(&clone)); } +// ── BSON interop (the whole point of format-conditional serde) ── +// +// Important: `bson::to_bson` defaults to `is_human_readable() == true` in +// bson 2.x — it's the "produce a structured Bson value" path. The actual +// MongoDB driver uses the raw serializer (`to_raw_document_buf`, internally +// called by `Collection::insert_one` / `find_one`), which sets +// `is_human_readable() == false`. The driver path is what matters in production; +// tests below exercise it. + +#[test] +fn bson_raw_serializes_as_native_object_id() { + use mongodb::bson::{RawBsonRef, RawDocumentBuf}; + + #[derive(serde::Serialize)] + struct Holder { + id: AffiliateId, + } + let oid = ObjectId::parse_str("665a1b2c3d4e5f6a7b8c9d0e").unwrap(); + let h = Holder { id: oid.into() }; + let raw: RawDocumentBuf = mongodb::bson::to_raw_document_buf(&h).unwrap(); + let value = raw.get("id").unwrap().unwrap(); + match value { + RawBsonRef::ObjectId(got) => assert_eq!(got, oid), + other => panic!("expected RawBson::ObjectId, got {other:?}"), + } +} + +#[test] +fn bson_raw_deserializes_from_native_object_id() { + use mongodb::bson::doc; + + #[derive(serde::Deserialize)] + struct Holder { + id: AffiliateId, + } + let oid = ObjectId::new(); + let doc = doc! { "id": oid }; + let bytes = mongodb::bson::to_vec(&doc).unwrap(); + let h: Holder = mongodb::bson::from_slice(&bytes).unwrap(); + assert_eq!(h.id.as_hex(), &oid.to_hex()); +} + +#[test] +fn one_struct_serves_both_bson_and_json() { + // The whole pattern: a struct that's both a MongoDB doc and an HTTP response. + // No Doc/Response split, no conversion helpers. + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] + struct Affiliate { + #[serde(rename = "_id")] + id: AffiliateId, + tenant_id: TenantId, + name: String, + } + + let original = Affiliate { + id: AffiliateId::new(), + tenant_id: TenantId::new(), + name: "Bcom".into(), + }; + + // BSON round-trip via the driver path (raw, non-human-readable). + let raw = mongodb::bson::to_raw_document_buf(&original).unwrap(); + // Verify fields landed as native ObjectId, not as string. + assert!(matches!( + raw.get("_id").unwrap().unwrap(), + mongodb::bson::RawBsonRef::ObjectId(_) + )); + assert!(matches!( + raw.get("tenant_id").unwrap().unwrap(), + mongodb::bson::RawBsonRef::ObjectId(_) + )); + // Round-trip back. + let bytes = mongodb::bson::to_vec(&original).unwrap(); + let from_bson: Affiliate = mongodb::bson::from_slice(&bytes).unwrap(); + assert_eq!(from_bson, original); + + // JSON round-trip — HTTP response path. Fields must be prefixed strings. + let json = serde_json::to_value(&original).unwrap(); + assert_eq!( + json["_id"], + format!("aff_{}", original.id.as_hex()).as_str() + ); + assert_eq!( + json["tenant_id"], + format!("tnt_{}", original.tenant_id.as_hex()).as_str() + ); + let from_json: Affiliate = serde_json::from_value(json).unwrap(); + assert_eq!(from_json, original); +} + +#[test] +fn new_generates_distinct_ids() { + let a = AffiliateId::new(); + let b = AffiliateId::new(); + assert_ne!(a, b); + assert_eq!(a.as_hex().len(), HEX_LEN); +} + // Compile-time: distinct marker types are not interchangeable. // fn _no_cross_assignment() { // let a: AffiliateId = ObjectId::new().into(); From c3d4ebadcca854fe687bf4035e0c1671fc5de8fe Mon Sep 17 00:00:00 2001 From: drei Date: Thu, 28 May 2026 14:46:50 -0500 Subject: [PATCH 3/5] Migrate affiliates to typed Id

+ supporting Id

ergonomics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `Id::new()` (generates a fresh ObjectId hex) and `Default` impl. - Add `From> for Bson` so `doc! { "_id": id }` produces a native BSON ObjectId without round-tripping through serde. Affiliates resource migrated end-to-end: - `services/affiliates/models.rs` — `Affiliate { id: AffiliateId, tenant_id: TenantId, .. }`, `AffiliateDetail.id` typed, `MintedCredential.affiliate_id` typed. File no longer imports `mongodb::bson::oid::ObjectId`. - `services/affiliates/repo.rs` — trait signatures take `&TenantId` / `&AffiliateId` (the `From> for Bson` impl lets the existing `doc!` queries work unchanged). - `services/affiliates/service.rs` — CRUD methods take `AffiliateId`; bridges to `ctx.tenant_id` (still `ObjectId` in AuthContext, 136 call sites — separate migration) via `.into()` at the boundary. Credential methods (`mint_credential`, `list_credentials`, `revoke_credential`) still take `ObjectId` until secret_keys migrates. - `api/affiliates/routes.rs` — CRUD route handlers use `Path` directly; no more manual `ObjectId::parse_str`. `to_detail` just clones the typed id. - `services/links/service.rs` and `services/billing/repos/resource_counts_adapter.rs` bridge their `&ObjectId` to the new typed signature with `.into()`. - `tests/common/mocks/affiliates.rs` updated to match the new trait. Removed `src/services/affiliates/models.rs` from `OBJECT_ID_BACKLOG`. `service.rs` and `routes.rs` stay on the backlog (credential paths still use ObjectId pending the secret_keys migration). Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/api/affiliates/routes.rs | 32 ++++++--------- server/src/architecture_tests.rs | 1 - server/src/core/public_id/mod.rs | 19 +++++++++ server/src/services/affiliates/models.rs | 18 ++++----- server/src/services/affiliates/repo.rs | 39 ++++++++++--------- server/src/services/affiliates/service.rs | 31 ++++++++------- .../billing/repos/resource_counts_adapter.rs | 4 +- server/src/services/links/service.rs | 2 +- server/tests/common/mocks/affiliates.rs | 22 +++++------ 9 files changed, 90 insertions(+), 78 deletions(-) diff --git a/server/src/api/affiliates/routes.rs b/server/src/api/affiliates/routes.rs index ea2b1e0..5e0399c 100644 --- a/server/src/api/affiliates/routes.rs +++ b/server/src/api/affiliates/routes.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use crate::api::auth::models::AuthKeyId; use crate::app::AppState; +use crate::core::public_id::AffiliateId; use crate::services::affiliates::models::*; use crate::services::auth::permissions::AuthContext; @@ -71,7 +72,7 @@ pub async fn list_affiliates( get, path = "/v1/affiliates/{affiliate_id}", tag = "Affiliates", - params(("affiliate_id" = String, Path, description = "Affiliate ObjectId")), + params(("affiliate_id" = AffiliateId, Path, description = "Affiliate id")), responses( (status = 200, description = "Affiliate detail", body = AffiliateDetail), (status = 404, description = "Not found", body = crate::error::ErrorResponse), @@ -82,16 +83,13 @@ pub async fn list_affiliates( pub async fn get_affiliate( State(state): State>, axum::Extension(ctx): axum::Extension, - Path(affiliate_id): Path, + Path(affiliate_id): Path, ) -> Response { let Some(svc) = &state.affiliates_service else { return no_database(); }; - let Ok(oid) = ObjectId::parse_str(&affiliate_id) else { - return invalid_id(); - }; - match svc.get_affiliate(&ctx, oid).await { + match svc.get_affiliate(&ctx, affiliate_id).await { Ok(a) => Json(to_detail(&a)).into_response(), Err(e) => affiliate_error_to_response(e), } @@ -101,7 +99,7 @@ pub async fn get_affiliate( patch, path = "/v1/affiliates/{affiliate_id}", tag = "Affiliates", - params(("affiliate_id" = String, Path, description = "Affiliate ObjectId")), + params(("affiliate_id" = AffiliateId, Path, description = "Affiliate id")), request_body = UpdateAffiliateRequest, responses( (status = 200, description = "Affiliate updated", body = AffiliateDetail), @@ -114,17 +112,14 @@ pub async fn get_affiliate( pub async fn patch_affiliate( State(state): State>, axum::Extension(ctx): axum::Extension, - Path(affiliate_id): Path, + Path(affiliate_id): Path, Json(req): Json, ) -> Response { let Some(svc) = &state.affiliates_service else { return no_database(); }; - let Ok(oid) = ObjectId::parse_str(&affiliate_id) else { - return invalid_id(); - }; - match svc.update_affiliate(&ctx, oid, req).await { + match svc.update_affiliate(&ctx, affiliate_id, req).await { Ok(a) => Json(to_detail(&a)).into_response(), Err(e) => affiliate_error_to_response(e), } @@ -134,7 +129,7 @@ pub async fn patch_affiliate( delete, path = "/v1/affiliates/{affiliate_id}", tag = "Affiliates", - params(("affiliate_id" = String, Path, description = "Affiliate ObjectId")), + params(("affiliate_id" = AffiliateId, Path, description = "Affiliate id")), responses( (status = 204, description = "Affiliate deleted"), (status = 404, description = "Not found", body = crate::error::ErrorResponse), @@ -145,16 +140,13 @@ pub async fn patch_affiliate( pub async fn delete_affiliate( State(state): State>, axum::Extension(ctx): axum::Extension, - Path(affiliate_id): Path, + Path(affiliate_id): Path, ) -> Response { let Some(svc) = &state.affiliates_service else { return no_database(); }; - let Ok(oid) = ObjectId::parse_str(&affiliate_id) else { - return invalid_id(); - }; - match svc.delete_affiliate(&ctx, oid).await { + match svc.delete_affiliate(&ctx, affiliate_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => affiliate_error_to_response(e), } @@ -197,7 +189,7 @@ pub async fn create_affiliate_credential( StatusCode::CREATED, Json(CreateAffiliateCredentialResponse { id: minted.created_key.id.to_hex(), - affiliate_id: minted.affiliate_id.to_hex(), + affiliate_id: minted.affiliate_id, api_key: minted.created_key.key, key_prefix: minted.created_key.key_prefix, created_at: minted @@ -292,7 +284,7 @@ pub async fn revoke_affiliate_credential( fn to_detail(a: &Affiliate) -> AffiliateDetail { AffiliateDetail { - id: a.id.to_hex(), + id: a.id.clone(), name: a.name.clone(), partner_key: a.partner_key.clone(), status: a.status, diff --git a/server/src/architecture_tests.rs b/server/src/architecture_tests.rs index 2376ec2..f2736cc 100644 --- a/server/src/architecture_tests.rs +++ b/server/src/architecture_tests.rs @@ -320,7 +320,6 @@ const OBJECT_ID_BACKLOG: &[&str] = &[ "src/api/lifecycle/routes.rs", "src/api/links/routes.rs", "src/api/webhooks/routes.rs", - "src/services/affiliates/models.rs", "src/services/affiliates/service.rs", "src/services/analytics/service.rs", "src/services/app_users/models.rs", diff --git a/server/src/core/public_id/mod.rs b/server/src/core/public_id/mod.rs index 1ee390e..0cb145b 100644 --- a/server/src/core/public_id/mod.rs +++ b/server/src/core/public_id/mod.rs @@ -15,6 +15,7 @@ use std::marker::PhantomData; use std::str::FromStr; use mongodb::bson::oid::ObjectId; +use mongodb::bson::Bson; use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub mod models; @@ -46,6 +47,12 @@ pub struct Id { _marker: PhantomData P>, } +impl Default for Id

{ + fn default() -> Self { + Self::new() + } +} + impl Id

{ /// Generate a fresh `Id` backed by a new `ObjectId`. Use this when creating /// a new resource that needs an ID assigned in application code (i.e. not @@ -106,6 +113,18 @@ impl From for Id

{ } } +// Direct conversion to `Bson` so `doc! { "_id": id }` produces a native ObjectId +// without round-tripping through serde. Required because `bson::Bson::from` is +// the path the `doc!` macro takes, and we want it to stay in the BSON-native +// representation (not become a string). The `From<&T>` reference variant comes +// from bson's blanket `impl> From<&T> for Bson`. +impl From> for Bson { + fn from(id: Id

) -> Self { + // Construction validates the hex, so unwrap is safe in practice. + Bson::ObjectId(ObjectId::parse_str(&id.hex).expect("Id

stores validated hex")) + } +} + impl Clone for Id

{ fn clone(&self) -> Self { Self { diff --git a/server/src/services/affiliates/models.rs b/server/src/services/affiliates/models.rs index e47d74c..78593a7 100644 --- a/server/src/services/affiliates/models.rs +++ b/server/src/services/affiliates/models.rs @@ -1,7 +1,9 @@ -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::core::public_id::{AffiliateId, TenantId}; + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, ToSchema)] #[serde(rename_all = "snake_case")] pub enum AffiliateStatus { @@ -20,8 +22,8 @@ pub enum AffiliateStatus { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Affiliate { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, + pub id: AffiliateId, + pub tenant_id: TenantId, pub name: String, pub partner_key: String, pub status: AffiliateStatus, @@ -46,8 +48,7 @@ pub struct CreateAffiliateRequest { #[derive(Debug, Serialize, ToSchema)] pub struct AffiliateDetail { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: AffiliateId, #[schema(example = "Bcom")] pub name: String, #[schema(example = "bcom")] @@ -81,12 +82,11 @@ pub struct UpdateAffiliateRequest { /// it again — list endpoints only return the prefix. #[derive(Debug, Serialize, ToSchema)] pub struct CreateAffiliateCredentialResponse { - /// Credential ObjectId (the secret key id). + /// Credential id (the secret key id). #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] pub id: String, /// Affiliate this credential is scoped to. - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] - pub affiliate_id: String, + pub affiliate_id: AffiliateId, /// Plaintext `rl_live_…` key. Shown only once. #[schema(example = "rl_live_4f2c3a8b9d0e1f2a3b4c5d6e7f8a9b0c")] pub api_key: String, @@ -193,5 +193,5 @@ impl AffiliateError { /// and must be shown to the caller exactly once. pub struct MintedCredential { pub created_key: CreatedKey, - pub affiliate_id: mongodb::bson::oid::ObjectId, + pub affiliate_id: AffiliateId, } diff --git a/server/src/services/affiliates/repo.rs b/server/src/services/affiliates/repo.rs index 7bf1116..083fdcd 100644 --- a/server/src/services/affiliates/repo.rs +++ b/server/src/services/affiliates/repo.rs @@ -1,8 +1,9 @@ use async_trait::async_trait; -use mongodb::bson::{doc, oid::ObjectId, DateTime}; +use mongodb::bson::{doc, DateTime}; use mongodb::options::IndexOptions; use mongodb::{Collection, Database}; +use crate::core::public_id::{AffiliateId, TenantId}; use crate::ensure_index; use super::models::{Affiliate, AffiliateStatus}; @@ -12,31 +13,31 @@ pub trait AffiliatesRepository: Send + Sync { async fn create_affiliate(&self, affiliate: &Affiliate) -> Result<(), String>; async fn get_by_id( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, ) -> Result, String>; async fn find_by_partner_key( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, partner_key: &str, ) -> Result, String>; - async fn list_by_tenant(&self, tenant_id: &ObjectId) -> Result, String>; + async fn list_by_tenant(&self, tenant_id: &TenantId) -> Result, String>; /// Total affiliates on this tenant — feeds the `CreateAffiliate` quota. - async fn count_by_tenant(&self, tenant_id: &ObjectId) -> Result; + async fn count_by_tenant(&self, tenant_id: &TenantId) -> Result; /// Apply optional updates. Returns `Ok(true)` if a row was touched, /// `Ok(false)` if no affiliate matched. Always bumps `updated_at`. async fn update_affiliate( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, name: Option<&str>, status: Option, now: DateTime, ) -> Result; async fn delete_affiliate( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, ) -> Result; } @@ -74,8 +75,8 @@ impl AffiliatesRepository for AffiliatesRepo { async fn get_by_id( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, ) -> Result, String> { self.affiliates .find_one(doc! { "_id": affiliate_id, "tenant_id": tenant_id }) @@ -85,7 +86,7 @@ impl AffiliatesRepository for AffiliatesRepo { async fn find_by_partner_key( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, partner_key: &str, ) -> Result, String> { self.affiliates @@ -94,7 +95,7 @@ impl AffiliatesRepository for AffiliatesRepo { .map_err(|e| e.to_string()) } - async fn list_by_tenant(&self, tenant_id: &ObjectId) -> Result, String> { + async fn list_by_tenant(&self, tenant_id: &TenantId) -> Result, String> { let mut cursor = self .affiliates .find(doc! { "tenant_id": tenant_id }) @@ -109,7 +110,7 @@ impl AffiliatesRepository for AffiliatesRepo { Ok(affiliates) } - async fn count_by_tenant(&self, tenant_id: &ObjectId) -> Result { + async fn count_by_tenant(&self, tenant_id: &TenantId) -> Result { self.affiliates .count_documents(doc! { "tenant_id": tenant_id }) .await @@ -118,8 +119,8 @@ impl AffiliatesRepository for AffiliatesRepo { async fn update_affiliate( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, name: Option<&str>, status: Option, now: DateTime, @@ -150,8 +151,8 @@ impl AffiliatesRepository for AffiliatesRepo { async fn delete_affiliate( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, ) -> Result { let result = self .affiliates diff --git a/server/src/services/affiliates/service.rs b/server/src/services/affiliates/service.rs index 15c42f2..59c6446 100644 --- a/server/src/services/affiliates/service.rs +++ b/server/src/services/affiliates/service.rs @@ -8,6 +8,7 @@ use super::models::{ MAX_CREDENTIALS_PER_AFFILIATE, }; use super::repo::AffiliatesRepository; +use crate::core::public_id::AffiliateId; use crate::services::auth::permissions::{AuthContext, Permission}; use crate::services::auth::secret_keys::repo::{KeyScope, SecretKeysRepository}; use crate::services::auth::secret_keys::service::mint_scoped; @@ -56,7 +57,7 @@ impl AffiliatesService { // backstop — see the E11000 catch below. if self .repo - .find_by_partner_key(&ctx.tenant_id, &partner_key) + .find_by_partner_key(&ctx.tenant_id.into(), &partner_key) .await .map_err(AffiliateError::Internal)? .is_some() @@ -66,8 +67,8 @@ impl AffiliatesService { let now = DateTime::now(); let affiliate = Affiliate { - id: ObjectId::new(), - tenant_id: ctx.tenant_id, + id: AffiliateId::new(), + tenant_id: ctx.tenant_id.into(), name, partner_key: partner_key.clone(), status: AffiliateStatus::Active, @@ -90,10 +91,10 @@ impl AffiliatesService { pub async fn get_affiliate( &self, ctx: &AuthContext, - affiliate_id: ObjectId, + affiliate_id: AffiliateId, ) -> Result { self.repo - .get_by_id(&ctx.tenant_id, &affiliate_id) + .get_by_id(&ctx.tenant_id.into(), &affiliate_id) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound) @@ -105,7 +106,7 @@ impl AffiliatesService { ctx: &AuthContext, ) -> Result, AffiliateError> { self.repo - .list_by_tenant(&ctx.tenant_id) + .list_by_tenant(&ctx.tenant_id.into()) .await .map_err(AffiliateError::Internal) } @@ -114,7 +115,7 @@ impl AffiliatesService { pub async fn update_affiliate( &self, ctx: &AuthContext, - affiliate_id: ObjectId, + affiliate_id: AffiliateId, req: UpdateAffiliateRequest, ) -> Result { if req.name.is_none() && req.status.is_none() { @@ -128,7 +129,7 @@ impl AffiliatesService { let updated = self .repo .update_affiliate( - &ctx.tenant_id, + &ctx.tenant_id.into(), &affiliate_id, req.name.as_deref(), req.status, @@ -144,7 +145,7 @@ impl AffiliatesService { // Re-fetch so the response carries the persisted values (including // the new updated_at). self.repo - .get_by_id(&ctx.tenant_id, &affiliate_id) + .get_by_id(&ctx.tenant_id.into(), &affiliate_id) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound) @@ -161,7 +162,7 @@ impl AffiliatesService { ) -> Result { // Affiliate must exist in this tenant. self.repo - .get_by_id(&ctx.tenant_id, &affiliate_id) + .get_by_id(&ctx.tenant_id.into(), &affiliate_id.into()) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound)?; @@ -188,7 +189,7 @@ impl AffiliatesService { Ok(MintedCredential { created_key, - affiliate_id, + affiliate_id: affiliate_id.into(), }) } @@ -203,7 +204,7 @@ impl AffiliatesService { ) -> Result, AffiliateError> { // Affiliate must exist (404 vs empty list — different semantics). self.repo - .get_by_id(&ctx.tenant_id, &affiliate_id) + .get_by_id(&ctx.tenant_id.into(), &affiliate_id.into()) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound)?; @@ -225,7 +226,7 @@ impl AffiliatesService { ) -> Result<(), AffiliateError> { // Surface affiliate-not-found distinctly from credential-not-found. self.repo - .get_by_id(&ctx.tenant_id, &affiliate_id) + .get_by_id(&ctx.tenant_id.into(), &affiliate_id.into()) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound)?; @@ -252,11 +253,11 @@ impl AffiliatesService { pub async fn delete_affiliate( &self, ctx: &AuthContext, - affiliate_id: ObjectId, + affiliate_id: AffiliateId, ) -> Result<(), AffiliateError> { let deleted = self .repo - .delete_affiliate(&ctx.tenant_id, &affiliate_id) + .delete_affiliate(&ctx.tenant_id.into(), &affiliate_id) .await .map_err(AffiliateError::Internal)?; diff --git a/server/src/services/billing/repos/resource_counts_adapter.rs b/server/src/services/billing/repos/resource_counts_adapter.rs index d1426cf..d604e4f 100644 --- a/server/src/services/billing/repos/resource_counts_adapter.rs +++ b/server/src/services/billing/repos/resource_counts_adapter.rs @@ -34,7 +34,9 @@ impl ResourceCounts for RepoResourceCounts { .await .map(|n| n as u64), Resource::CreateWebhook => self.webhooks.count_by_tenant(tenant_id).await, - Resource::CreateAffiliate => self.affiliates.count_by_tenant(tenant_id).await, + Resource::CreateAffiliate => { + self.affiliates.count_by_tenant(&(*tenant_id).into()).await + } // TrackEvent uses the atomic counter path, not ResourceCounts. Resource::TrackEvent => Ok(0), } diff --git a/server/src/services/links/service.rs b/server/src/services/links/service.rs index 28e97de..6b5e715 100644 --- a/server/src/services/links/service.rs +++ b/server/src/services/links/service.rs @@ -968,7 +968,7 @@ impl LinksService { .affiliates_repo .as_ref() .ok_or(LinkError::AffiliateNotFound)?; - repo.get_by_id(tenant_id, &req) + repo.get_by_id(&(*tenant_id).into(), &req.into()) .await .map_err(LinkError::Internal)? .ok_or(LinkError::AffiliateNotFound)?; diff --git a/server/tests/common/mocks/affiliates.rs b/server/tests/common/mocks/affiliates.rs index cd9963b..1a94f96 100644 --- a/server/tests/common/mocks/affiliates.rs +++ b/server/tests/common/mocks/affiliates.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; -use mongodb::bson::oid::ObjectId; use mongodb::bson::DateTime; use std::sync::Mutex; +use rift::core::public_id::{AffiliateId, TenantId}; use rift::services::affiliates::models::{Affiliate, AffiliateStatus}; use rift::services::affiliates::repo::AffiliatesRepository; @@ -15,7 +15,6 @@ pub struct MockAffiliatesRepo { impl AffiliatesRepository for MockAffiliatesRepo { async fn create_affiliate(&self, affiliate: &Affiliate) -> Result<(), String> { let mut store = self.affiliates.lock().unwrap(); - // Mirror the unique compound index: (tenant_id, partner_key). if store .iter() .any(|a| a.tenant_id == affiliate.tenant_id && a.partner_key == affiliate.partner_key) @@ -28,8 +27,8 @@ impl AffiliatesRepository for MockAffiliatesRepo { async fn get_by_id( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, ) -> Result, String> { Ok(self .affiliates @@ -42,7 +41,7 @@ impl AffiliatesRepository for MockAffiliatesRepo { async fn find_by_partner_key( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, partner_key: &str, ) -> Result, String> { Ok(self @@ -54,7 +53,7 @@ impl AffiliatesRepository for MockAffiliatesRepo { .cloned()) } - async fn list_by_tenant(&self, tenant_id: &ObjectId) -> Result, String> { + async fn list_by_tenant(&self, tenant_id: &TenantId) -> Result, String> { let mut affiliates: Vec = self .affiliates .lock() @@ -63,12 +62,11 @@ impl AffiliatesRepository for MockAffiliatesRepo { .filter(|a| &a.tenant_id == tenant_id) .cloned() .collect(); - // Match production sort: created_at desc. affiliates.sort_by_key(|a| std::cmp::Reverse(a.created_at)); Ok(affiliates) } - async fn count_by_tenant(&self, tenant_id: &ObjectId) -> Result { + async fn count_by_tenant(&self, tenant_id: &TenantId) -> Result { Ok(self .affiliates .lock() @@ -80,8 +78,8 @@ impl AffiliatesRepository for MockAffiliatesRepo { async fn update_affiliate( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, name: Option<&str>, status: Option, now: DateTime, @@ -105,8 +103,8 @@ impl AffiliatesRepository for MockAffiliatesRepo { async fn delete_affiliate( &self, - tenant_id: &ObjectId, - affiliate_id: &ObjectId, + tenant_id: &TenantId, + affiliate_id: &AffiliateId, ) -> Result { let mut store = self.affiliates.lock().unwrap(); let len = store.len(); From becc0ec673158672b704b1b01da622982ad7930b Mon Sep 17 00:00:00 2001 From: drei Date: Thu, 28 May 2026 15:32:06 -0500 Subject: [PATCH 4/5] =?UTF-8?q?Unify=20TenantId/UserId/AffiliateId=20throu?= =?UTF-8?q?gh=20axum=20extensions=20=E2=86=92=20AuthContext=20=E2=86=92=20?= =?UTF-8?q?services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker-type safety story now actually delivers: there is one typed `TenantId`, one typed `UserId`, one typed `AffiliateId` across the entire codebase. They flow unchanged from the auth middleware through services down to repo boundaries. Restructured `Id

`: - Stores `ObjectId` directly instead of a `String` hex (fixes the agent's perf + reachable-`expect` concerns). `to_object_id()` is now infallible. - `Copy` derived — eliminates `clone()` noise at every plumbing call site. - Removed the blanket `impl From for Id

` — conversions must name the target type via `TenantId::from_object_id(oid)`, so cross-resource mixups are caught at review. - Dropped `Default` — defaults must be cheap/deterministic; `new()` mints a fresh ObjectId (clock + counter) and is explicit. - `SecretKeyIdMarker::PREFIX` changed from `"sk"` to `"skid"` to avoid collision with Stripe's `sk_live_` / `sk_test_` muscle memory. Unification: - `api/auth/models.rs` re-exports `core::public_id::{TenantId, UserId}` — the old `TenantId(pub ObjectId)` / `UserId(pub ObjectId)` newtypes are gone. `Extension` is the typed value end-to-end. - `auth/middleware.rs` constructs `TenantId::from_object_id(oid)` / `UserId::from_object_id(oid)` once at the auth boundary. - `AuthContext.tenant_id: TenantId`, `Principal::User { user_id: UserId, .. }`, `ResourceScope::Affiliate { affiliate_id: AffiliateId }`. - ~28 axum extension call sites flow the typed value through (no `.0` access). - Affiliate credentials (`mint_credential` / `list_credentials` / `revoke_credential`) take `AffiliateId` — affiliates is now fully migrated. Bridges to un-migrated layers: - `ctx.tenant_id.as_object_id()` for repos that still take `&ObjectId` (most repos; per-resource migration is follow-up work). - `ctx.tenant_id.to_object_id()` for owned ObjectId needs (BSON metadata, Sentry tags via `.to_string()`). - `affiliate_id.to_object_id()` when calling secret_keys repo (still ObjectId). Architecture test: - Tightened `*_tests.rs` allowlist: a `*_tests.rs` file now only counts as a sibling-test if there's another non-test `.rs` file in the same directory. Closes the "name any file `_tests.rs` to bypass" escape. - `architecture_tests.rs` itself added to the explicit allowlist (mentions the word in parser tests and error messages). Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/api/affiliates/routes.rs | 17 +++- server/src/api/apps/routes.rs | 6 +- server/src/api/auth/middleware.rs | 33 ++++--- server/src/api/auth/models.rs | 28 ++---- .../src/api/auth/publishable_keys/routes.rs | 8 +- server/src/api/auth/sessions/routes.rs | 4 +- server/src/api/billing/routes.rs | 6 +- server/src/api/conversions/routes.rs | 20 ++-- server/src/api/domains/routes.rs | 6 +- server/src/api/lifecycle/routes.rs | 14 ++- server/src/api/links/qr.rs | 2 +- server/src/api/webhooks/routes.rs | 17 +++- server/src/architecture_tests.rs | 32 +++++- server/src/core/public_id/mod.rs | 97 +++++++++---------- server/src/core/public_id/public_id_tests.rs | 52 ++++++---- server/src/mcp/server.rs | 6 +- server/src/services/affiliates/service.rs | 53 ++++++---- server/src/services/analytics/service.rs | 2 +- .../src/services/auth/permissions/context.rs | 9 +- .../auth/permissions/context_tests.rs | 22 +++-- .../src/services/auth/permissions/models.rs | 15 +-- .../src/services/auth/secret_keys/service.rs | 24 +++-- server/src/services/auth/users/service.rs | 13 +-- .../billing/repos/resource_counts_adapter.rs | 6 +- server/src/services/billing/service.rs | 2 +- server/src/services/billing/service_tests.rs | 6 +- server/src/services/domains/service.rs | 16 ++- server/src/services/links/service.rs | 38 +++++--- server/src/services/links/service_tests.rs | 6 +- server/src/services/webhooks/service.rs | 5 +- 30 files changed, 346 insertions(+), 219 deletions(-) diff --git a/server/src/api/affiliates/routes.rs b/server/src/api/affiliates/routes.rs index 5e0399c..95c3c8a 100644 --- a/server/src/api/affiliates/routes.rs +++ b/server/src/api/affiliates/routes.rs @@ -184,7 +184,10 @@ pub async fn create_affiliate_credential( return invalid_id(); }; - match svc.mint_credential(&ctx, oid, auth_key.0).await { + match svc + .mint_credential(&ctx, AffiliateId::from_object_id(oid), auth_key.0) + .await + { Ok(minted) => ( StatusCode::CREATED, Json(CreateAffiliateCredentialResponse { @@ -228,7 +231,10 @@ pub async fn list_affiliate_credentials( return invalid_id(); }; - match svc.list_credentials(&ctx, oid).await { + match svc + .list_credentials(&ctx, AffiliateId::from_object_id(oid)) + .await + { Ok(keys) => { let creds: Vec = keys .into_iter() @@ -274,7 +280,10 @@ pub async fn revoke_affiliate_credential( return invalid_id(); }; - match svc.revoke_credential(&ctx, aff_oid, key_oid).await { + match svc + .revoke_credential(&ctx, AffiliateId::from_object_id(aff_oid), key_oid) + .await + { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => affiliate_error_to_response(e), } @@ -284,7 +293,7 @@ pub async fn revoke_affiliate_credential( fn to_detail(a: &Affiliate) -> AffiliateDetail { AffiliateDetail { - id: a.id.clone(), + id: a.id, name: a.name.clone(), partner_key: a.partner_key.clone(), status: a.status, diff --git a/server/src/api/apps/routes.rs b/server/src/api/apps/routes.rs index 78cdc6e..995c087 100644 --- a/server/src/api/apps/routes.rs +++ b/server/src/api/apps/routes.rs @@ -91,7 +91,7 @@ pub async fn create_app( let app = crate::services::apps::models::App { id: ObjectId::new(), - tenant_id: tenant.0, + tenant_id: tenant.to_object_id(), platform: platform.clone(), bundle_id: req.bundle_id, team_id: req.team_id, @@ -147,7 +147,7 @@ pub async fn list_apps( .into_response(); }; - match repo.list_by_tenant(&tenant.0).await { + match repo.list_by_tenant(&tenant.to_object_id()).await { Ok(apps) => { let details: Vec = apps.iter().map(to_detail).collect(); Json(json!({ "apps": details })).into_response() @@ -198,7 +198,7 @@ pub async fn delete_app( .into_response(); }; - match repo.delete_app(&tenant.0, &oid).await { + match repo.delete_app(&tenant.to_object_id(), &oid).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, diff --git a/server/src/api/auth/middleware.rs b/server/src/api/auth/middleware.rs index be8ab4d..c397a77 100644 --- a/server/src/api/auth/middleware.rs +++ b/server/src/api/auth/middleware.rs @@ -64,10 +64,11 @@ pub async fn auth_gate( } // Inject tenant identity, key identity, and scope for downstream handlers. - req.extensions_mut().insert(TenantId(tenant_id)); + req.extensions_mut() + .insert(TenantId::from_object_id(tenant_id)); req.extensions_mut().insert(AuthKeyId(key_id)); req.extensions_mut().insert(AuthContext::for_secret_key( - tenant_id, + TenantId::from_object_id(tenant_id), key_id, scope.as_ref(), )); @@ -214,12 +215,14 @@ pub async fn session_auth_gate( match svc.lookup(&raw_token).await { Ok(Some(resolved)) => { - req.extensions_mut().insert(TenantId(resolved.tenant_id)); - req.extensions_mut().insert(UserId(resolved.user_id)); + req.extensions_mut() + .insert(TenantId::from_object_id(resolved.tenant_id)); + req.extensions_mut() + .insert(UserId::from_object_id(resolved.user_id)); req.extensions_mut().insert(SessionId(resolved.session_id)); req.extensions_mut().insert(AuthContext::for_session( - resolved.tenant_id, - resolved.user_id, + TenantId::from_object_id(resolved.tenant_id), + UserId::from_object_id(resolved.user_id), resolved.session_id, )); @@ -271,12 +274,14 @@ pub async fn session_or_key_auth_gate( { match svc.lookup(&raw_token).await { Ok(Some(resolved)) => { - req.extensions_mut().insert(TenantId(resolved.tenant_id)); - req.extensions_mut().insert(UserId(resolved.user_id)); + req.extensions_mut() + .insert(TenantId::from_object_id(resolved.tenant_id)); + req.extensions_mut() + .insert(UserId::from_object_id(resolved.user_id)); req.extensions_mut().insert(SessionId(resolved.session_id)); req.extensions_mut().insert(AuthContext::for_session( - resolved.tenant_id, - resolved.user_id, + TenantId::from_object_id(resolved.tenant_id), + UserId::from_object_id(resolved.user_id), resolved.session_id, )); @@ -325,10 +330,11 @@ pub async fn session_or_key_auth_gate( } } - req.extensions_mut().insert(TenantId(tenant_id)); + req.extensions_mut() + .insert(TenantId::from_object_id(tenant_id)); req.extensions_mut().insert(AuthKeyId(key_id)); req.extensions_mut().insert(AuthContext::for_secret_key( - tenant_id, + TenantId::from_object_id(tenant_id), key_id, scope.as_ref(), )); @@ -444,7 +450,8 @@ pub async fn sdk_auth_gate( .into_response(); } - req.extensions_mut().insert(TenantId(doc.tenant_id)); + req.extensions_mut() + .insert(TenantId::from_object_id(doc.tenant_id)); req.extensions_mut().insert(SdkDomain(doc.domain)); next.run(req).await diff --git a/server/src/api/auth/models.rs b/server/src/api/auth/models.rs index 27e0de9..7f7e6f7 100644 --- a/server/src/api/auth/models.rs +++ b/server/src/api/auth/models.rs @@ -1,34 +1,24 @@ //! Axum extension types injected by `api/auth/middleware.rs` into request //! extensions, then extracted by route handlers via `Extension<...>`. //! -//! Service-layer authorization travels through `AuthContext` (see -//! `services/auth/permissions/`). These extensions remain for the route -//! layer to use directly — `AuthKeyId` for the affiliate credential -//! provenance, `UserId`/`SessionId` for session-bound flows, `SdkDomain` -//! for the SDK path. `TenantId` is kept for routes that bypass the -//! service layer (e.g. webhook list/delete that call the repo directly). +//! `TenantId` and `UserId` are re-exports of the typed identifiers from +//! `core::public_id` — there's no separate axum-extension newtype. The +//! middleware constructs the typed value once at the auth boundary and the +//! same type flows all the way through services and repos. +//! +//! `AuthKeyId`, `SessionId`, `SdkDomain` are still local newtypes — they +//! aren't yet migrated to typed `Id

` aliases. Doing so is part of the +//! secret_keys / sessions migrations. use mongodb::bson::oid::ObjectId; -/// Tenant identity injected by the auth middleware. -/// Handlers extract this via `Extension`. -#[derive(Debug, Clone)] -pub struct TenantId(pub ObjectId); +pub use crate::core::public_id::{TenantId, UserId}; /// The ObjectId of the secret key used for authentication. /// Handlers extract this via `Extension`. #[derive(Debug, Clone)] pub struct AuthKeyId(pub ObjectId); -/// Human identity for session-authenticated requests. -/// -/// Only injected by `session_auth_gate` and `session_or_key_auth_gate` (when the -/// session path wins). Key-only routes never see this; session-only handlers -/// can extract it via `Extension`. Handlers wrapped with `session_or_key_auth_gate` -/// should treat it as optional (`Option>`). -#[derive(Debug, Clone)] -pub struct UserId(pub ObjectId); - /// The active session's ObjectId — used by `POST /v1/auth/signout` to revoke /// the exact session the caller arrived on. #[derive(Debug, Clone)] diff --git a/server/src/api/auth/publishable_keys/routes.rs b/server/src/api/auth/publishable_keys/routes.rs index 90c42a9..70e53c0 100644 --- a/server/src/api/auth/publishable_keys/routes.rs +++ b/server/src/api/auth/publishable_keys/routes.rs @@ -66,7 +66,7 @@ pub async fn create_sdk_key( } }; - if domain.tenant_id != tenant.0 { + if domain.tenant_id != tenant.to_object_id() { return ( StatusCode::BAD_REQUEST, Json(json!({ "error": "Domain not owned by this tenant", "code": "domain_not_owned" })), @@ -86,7 +86,7 @@ pub async fn create_sdk_key( let now = DateTime::now(); let doc = SdkKeyDoc { id: ObjectId::new(), - tenant_id: tenant.0, + tenant_id: tenant.to_object_id(), key_hash: hash, key_prefix: prefix, domain: req.domain.clone(), @@ -139,7 +139,7 @@ pub async fn list_sdk_keys( .into_response(); }; - match sdk_keys_repo.list_by_tenant(&tenant.0).await { + match sdk_keys_repo.list_by_tenant(&tenant.to_object_id()).await { Ok(docs) => { let keys: Vec = docs .iter() @@ -198,7 +198,7 @@ pub async fn revoke_sdk_key( .into_response(); }; - match sdk_keys_repo.revoke(&tenant.0, &oid).await { + match sdk_keys_repo.revoke(&tenant.to_object_id(), &oid).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, diff --git a/server/src/api/auth/sessions/routes.rs b/server/src/api/auth/sessions/routes.rs index c07b643..b4ddb78 100644 --- a/server/src/api/auth/sessions/routes.rs +++ b/server/src/api/auth/sessions/routes.rs @@ -272,7 +272,7 @@ pub async fn me( } }; - let Some(user_detail) = users.into_iter().find(|u| u.id == user.0) else { + let Some(user_detail) = users.into_iter().find(|u| u.id == user.to_object_id()) else { // Session points at a user that no longer exists. Treat as a stale // session — caller should re-sign-in. return ( @@ -290,7 +290,7 @@ pub async fn me( is_owner: user_detail.is_owner, }, tenant: TenantSummary { - id: ctx.tenant_id.to_hex(), + id: ctx.tenant_id.as_hex(), }, }) .into_response() diff --git a/server/src/api/billing/routes.rs b/server/src/api/billing/routes.rs index 2d2b079..e44f89b 100644 --- a/server/src/api/billing/routes.rs +++ b/server/src/api/billing/routes.rs @@ -104,7 +104,7 @@ pub async fn create_stripe_checkout( cancel_url, }; - match create_checkout_session(&cfg, tier, &tenant.0.to_hex()).await { + match create_checkout_session(&cfg, tier, &tenant.to_object_id().to_hex()).await { Ok(session) => ( StatusCode::OK, Json(CheckoutSessionResponse { @@ -170,7 +170,7 @@ pub async fn create_stripe_portal( .into_response(); }; - let tenant_doc = match tenants.find_by_id(&tenant.0).await { + let tenant_doc = match tenants.find_by_id(&tenant.to_object_id()).await { Ok(Some(t)) => t, Ok(None) => { return ( @@ -286,7 +286,7 @@ pub async fn cancel_subscription( ) .into_response(); }; - let tenant_doc = match tenants.find_by_id(&tenant.0).await { + let tenant_doc = match tenants.find_by_id(&tenant.to_object_id()).await { Ok(Some(t)) => t, Ok(None) => { return ( diff --git a/server/src/api/conversions/routes.rs b/server/src/api/conversions/routes.rs index f2be953..996ab17 100644 --- a/server/src/api/conversions/routes.rs +++ b/server/src/api/conversions/routes.rs @@ -51,7 +51,10 @@ pub async fn create_source( .into_response(); } - match repo.create_source(tenant.0, name, req.source_type).await { + match repo + .create_source(tenant.to_object_id(), name, req.source_type) + .await + { Ok(source) => { let resp = CreateSourceResponse { id: source.id.to_hex(), @@ -111,7 +114,7 @@ pub async fn list_sources( .into_response(); }; - let mut sources = match repo.list_sources(&tenant.0).await { + let mut sources = match repo.list_sources(&tenant.to_object_id()).await { Ok(s) => s, Err(e) => { tracing::error!(error = %e, "Failed to list sources"); @@ -126,7 +129,10 @@ pub async fn list_sources( // Auto-provision a default custom source if the tenant has none. This is the // zero-ceremony dev flow: first GET returns a usable webhook URL immediately. if sources.is_empty() { - match repo.get_or_create_default_custom_source(tenant.0).await { + match repo + .get_or_create_default_custom_source(tenant.to_object_id()) + .await + { Ok(source) => sources.push(source), Err(e) => { tracing::error!(error = %e, "Failed to auto-provision default source"); @@ -178,7 +184,7 @@ pub async fn get_source( .into_response(); }; - match repo.find_source_by_id(&tenant.0, &oid).await { + match repo.find_source_by_id(&tenant.to_object_id(), &oid).await { Ok(Some(source)) => Json(to_detail(&state, &source)).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, @@ -231,7 +237,7 @@ pub async fn delete_source( .into_response(); }; - match repo.delete_source(&tenant.0, &oid).await { + match repo.delete_source(&tenant.to_object_id(), &oid).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, @@ -360,7 +366,9 @@ pub async fn sdk_track_conversion( occurred_at: None, }]; - let result = service.ingest_sdk_event(tenant.0, parsed).await; + let result = service + .ingest_sdk_event(tenant.to_object_id(), parsed) + .await; Json(json!({ "accepted": result.accepted, diff --git a/server/src/api/domains/routes.rs b/server/src/api/domains/routes.rs index fb3acfb..b1adc5e 100644 --- a/server/src/api/domains/routes.rs +++ b/server/src/api/domains/routes.rs @@ -167,7 +167,7 @@ pub async fn list_domains( .into_response(); }; - match repo.list_by_tenant(&tenant.0).await { + match repo.list_by_tenant(&tenant.to_object_id()).await { Ok(domains) => { let details: Vec = domains .iter() @@ -221,7 +221,7 @@ pub async fn delete_domain( .into_response(); }; - match repo.delete_domain(&tenant.0, &domain).await { + match repo.delete_domain(&tenant.to_object_id(), &domain).await { Ok(true) => { // Remove TLS certificate from Fly.io (best-effort). // DB is authoritative — orphaned certs are harmless and can be cleaned up later. @@ -300,7 +300,7 @@ pub async fn verify_domain( .into_response(); }; - if existing.tenant_id != tenant.0 { + if existing.tenant_id != tenant.to_object_id() { return ( StatusCode::NOT_FOUND, Json(json!({ "error": "Domain not found", "code": "not_found" })), diff --git a/server/src/api/lifecycle/routes.rs b/server/src/api/lifecycle/routes.rs index 23f8b00..4d28be5 100644 --- a/server/src/api/lifecycle/routes.rs +++ b/server/src/api/lifecycle/routes.rs @@ -65,7 +65,7 @@ pub async fn lifecycle_click( }; let link = repo - .find_link_by_tenant_and_id(&tenant.0, &req.link_id) + .find_link_by_tenant_and_id(&tenant.to_object_id(), &req.link_id) .await .ok() .flatten(); @@ -176,7 +176,7 @@ pub async fn lifecycle_attribute( }; let link = repo - .find_link_by_tenant_and_id(&tenant.0, &req.link_id) + .find_link_by_tenant_and_id(&tenant.to_object_id(), &req.link_id) .await .ok() .flatten(); @@ -283,7 +283,7 @@ pub async fn lifecycle_identify( }; match svc - .identify_install(&tenant.0, &req.install_id, &req.user_id) + .identify_install(&tenant.to_object_id(), &req.install_id, &req.user_id) .await { Ok(IdentifyOutcome::Created(credited)) | Ok(IdentifyOutcome::InstallAdded(credited)) => { @@ -294,7 +294,13 @@ pub async fn lifecycle_identify( user_id = %req.user_id, "identify bound; firing webhook" ); - fire_identify_event(&state, &tenant.0, &req.install_id, &req.user_id, credited); + fire_identify_event( + &state, + &tenant.to_object_id(), + &req.install_id, + &req.user_id, + credited, + ); Json(json!({ "success": true })).into_response() } Ok(IdentifyOutcome::AlreadyPresent) => { diff --git a/server/src/api/links/qr.rs b/server/src/api/links/qr.rs index dc2cd0d..3f2f18e 100644 --- a/server/src/api/links/qr.rs +++ b/server/src/api/links/qr.rs @@ -50,7 +50,7 @@ pub(crate) async fn render_link_qr( }; let Some(link) = repo - .find_link_by_tenant_and_id(&tenant.0, &link_id) + .find_link_by_tenant_and_id(&tenant.to_object_id(), &link_id) .await .ok() .flatten() diff --git a/server/src/api/webhooks/routes.rs b/server/src/api/webhooks/routes.rs index 5502774..7afc48b 100644 --- a/server/src/api/webhooks/routes.rs +++ b/server/src/api/webhooks/routes.rs @@ -112,7 +112,7 @@ pub async fn list_webhooks( .into_response(); }; - match repo.list_by_tenant(&tenant.0).await { + match repo.list_by_tenant(&tenant.to_object_id()).await { Ok(webhooks) => { let details: Vec = webhooks .into_iter() @@ -170,7 +170,7 @@ pub async fn delete_webhook( .into_response(); }; - match repo.delete_webhook(&tenant.0, &oid).await { + match repo.delete_webhook(&tenant.to_object_id(), &oid).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, @@ -256,12 +256,21 @@ pub async fn patch_webhook( } match repo - .update_webhook(&tenant.0, &oid, req.active, req.events, req.url) + .update_webhook( + &tenant.to_object_id(), + &oid, + req.active, + req.events, + req.url, + ) .await { Ok(true) => { // Fetch updated webhook to return. - let webhooks = repo.list_by_tenant(&tenant.0).await.unwrap_or_default(); + let webhooks = repo + .list_by_tenant(&tenant.to_object_id()) + .await + .unwrap_or_default(); match webhooks.iter().find(|w| w.id == oid) { Some(w) => Json(json!({ "id": w.id.to_hex(), diff --git a/server/src/architecture_tests.rs b/server/src/architecture_tests.rs index f2736cc..e0f29ac 100644 --- a/server/src/architecture_tests.rs +++ b/server/src/architecture_tests.rs @@ -757,6 +757,9 @@ const OBJECT_ID_ALLOWED_FILES: &[&str] = &[ "src/main.rs", "src/core/db.rs", "src/core/public_id/mod.rs", + // architecture_tests.rs scans for the word `ObjectId` — it has to mention + // it (in comments, parser test strings, and the error message). + "src/architecture_tests.rs", ]; fn is_object_id_allowed(rel_str: &str) -> bool { @@ -775,13 +778,38 @@ fn is_object_id_allowed(rel_str: &str) -> bool { if rel_str.contains("/repos/") { return true; } - // Sibling test files may reference any type. + // Sibling test files may reference any type — but ONLY if they sit next + // to a non-test `.rs` source file in the same directory. Without this + // gate, anyone could defeat the rule by naming any file `*_tests.rs`. + // The check covers both: + // - `_tests.rs` next to `.rs` (e.g. `origin_tests.rs` next to `origin.rs`) + // - `_tests.rs` next to `mod.rs` (sub-module pattern, e.g. + // `core/public_id/public_id_tests.rs` next to `core/public_id/mod.rs`) if let Some(name) = std::path::Path::new(rel_str) .file_name() .and_then(|s| s.to_str()) { if name.ends_with("_tests.rs") { - return true; + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let abs = std::path::Path::new(&manifest_dir).join(rel_str); + if let Some(parent) = abs.parent() { + // Look for any sibling `.rs` that isn't another test file. + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let p = entry.path(); + if p == abs { + continue; + } + if p.extension().and_then(|s| s.to_str()) != Some("rs") { + continue; + } + let n = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if !n.ends_with("_tests.rs") { + return true; + } + } + } + } } } false diff --git a/server/src/core/public_id/mod.rs b/server/src/core/public_id/mod.rs index 0cb145b..71942ab 100644 --- a/server/src/core/public_id/mod.rs +++ b/server/src/core/public_id/mod.rs @@ -36,44 +36,50 @@ pub trait IdPrefix { pub const HEX_LEN: usize = 24; crate::impl_container!(Id); -/// Typed prefixed identifier wrapping a raw ObjectId hex string. +/// Typed prefixed identifier wrapping a MongoDB `ObjectId`. The wire format +/// adds the resource prefix (`_<24-char-lowercase-hex>`) at serialize +/// time; BSON serialization emits the native `ObjectId` so a single struct +/// works as both a MongoDB document and an HTTP response. /// -/// Construction goes through `from_object_id` (from a repo) or `parse` (from the wire); -/// both validate the body is exactly 24 lowercase hex chars. Serializes as -/// `_`; deserializes by checking the prefix matches `P::PREFIX`. +/// Construction is via `Id::from_object_id` (repo / middleware boundary) or +/// `Id::parse` (wire-format strings). There is intentionally **no** blanket +/// `From` — every `ObjectId` → `Id

` conversion must name the +/// target type so cross-resource ID mixups are caught at review time. pub struct Id { - /// The raw 24-char ObjectId hex. **No prefix.** - hex: String, + inner: ObjectId, _marker: PhantomData P>, } -impl Default for Id

{ - fn default() -> Self { - Self::new() - } -} - impl Id

{ /// Generate a fresh `Id` backed by a new `ObjectId`. Use this when creating /// a new resource that needs an ID assigned in application code (i.e. not /// letting MongoDB generate `_id` on insert). + /// + /// Intentionally NOT `Default::default()` — defaults should be cheap and + /// deterministic; this mints a fresh ObjectId (clock + counter). + #[allow(clippy::new_without_default)] pub fn new() -> Self { Self::from_object_id(ObjectId::new()) } - /// Construct from a MongoDB ObjectId. Repo layer only. + /// Construct from a MongoDB ObjectId. Repo / middleware layer only. pub fn from_object_id(oid: ObjectId) -> Self { Self { - hex: oid.to_hex(), + inner: oid, _marker: PhantomData, } } - /// Convert back to an `ObjectId` for storage queries. Repo layer only. - /// Infallible in practice because construction validates the hex body — - /// returns `Err` only if the underlying hex was somehow corrupted. - pub fn to_object_id(&self) -> Result { - ObjectId::parse_str(&self.hex).map_err(|_| ParseIdError::InvalidHex) + /// Borrow the underlying `ObjectId`. Infallible — there's no parsing. + pub fn as_object_id(&self) -> &ObjectId { + &self.inner + } + + /// Convert to an owned `ObjectId` for storage queries. Infallible — the + /// `Id

` stores a parsed `ObjectId` directly. + #[allow(clippy::wrong_self_convention)] // `Id

` is Copy; `&self` matches the `&id.to_object_id()` call sites. + pub fn to_object_id(&self) -> ObjectId { + self.inner } /// Parse `_<24-char-hex>`. The body must be lowercase hex (matching @@ -95,55 +101,45 @@ impl Id

{ if !body.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { return Err(ParseIdError::InvalidHex); } + let oid = ObjectId::parse_str(body).map_err(|_| ParseIdError::InvalidHex)?; Ok(Self { - hex: body.to_string(), + inner: oid, _marker: PhantomData, }) } - /// Borrow the raw hex body (no prefix). Repo layer use. - pub fn as_hex(&self) -> &str { - &self.hex - } -} - -impl From for Id

{ - fn from(oid: ObjectId) -> Self { - Self::from_object_id(oid) + /// The raw 24-char lowercase hex of the underlying ObjectId (no prefix). + pub fn as_hex(&self) -> String { + self.inner.to_hex() } } // Direct conversion to `Bson` so `doc! { "_id": id }` produces a native ObjectId -// without round-tripping through serde. Required because `bson::Bson::from` is -// the path the `doc!` macro takes, and we want it to stay in the BSON-native -// representation (not become a string). The `From<&T>` reference variant comes +// without round-tripping through serde. The `From<&T>` reference variant comes // from bson's blanket `impl> From<&T> for Bson`. impl From> for Bson { fn from(id: Id

) -> Self { - // Construction validates the hex, so unwrap is safe in practice. - Bson::ObjectId(ObjectId::parse_str(&id.hex).expect("Id

stores validated hex")) + Bson::ObjectId(id.inner) } } +impl Copy for Id

{} impl Clone for Id

{ fn clone(&self) -> Self { - Self { - hex: self.hex.clone(), - _marker: PhantomData, - } + *self } } impl PartialEq for Id

{ fn eq(&self, other: &Self) -> bool { - self.hex == other.hex + self.inner == other.inner } } impl Eq for Id

{} impl std::hash::Hash for Id

{ fn hash(&self, state: &mut H) { - self.hex.hash(state); + self.inner.hash(state); } } @@ -154,19 +150,19 @@ impl PartialOrd for Id

{ } impl Ord for Id

{ fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.hex.cmp(&other.hex) + self.inner.cmp(&other.inner) } } impl fmt::Display for Id

{ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}_{}", P::PREFIX, self.hex) + write!(f, "{}_{}", P::PREFIX, self.inner.to_hex()) } } impl fmt::Debug for Id

{ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "\"{}_{}\"", P::PREFIX, self.hex) + write!(f, "\"{}_{}\"", P::PREFIX, self.inner.to_hex()) } } @@ -181,14 +177,13 @@ impl Serialize for Id

{ fn serialize(&self, serializer: S) -> Result { if serializer.is_human_readable() { // JSON / OpenAPI / MCP wire format: prefixed string. - serializer.collect_str(&format_args!("{}_{}", P::PREFIX, self.hex)) + serializer.collect_str(&format_args!("{}_{}", P::PREFIX, self.inner.to_hex())) } else { - // BSON (and any other binary format that opts out of human-readable): + // BSON (raw, non-human-readable — what the mongodb driver uses): // serialize as a native ObjectId so MongoDB stores `_id` in its - // canonical form. This is the bridge that lets a single struct serve - // both as a BSON document and an HTTP response. - let oid = ObjectId::parse_str(&self.hex).map_err(serde::ser::Error::custom)?; - oid.serialize(serializer) + // canonical form. This is the bridge that lets a single struct + // serve both as a BSON document and an HTTP response. + self.inner.serialize(serializer) } } } @@ -295,7 +290,9 @@ impl IdPrefix for PublishableKeyIdMarker { crate::impl_container!(SecretKeyIdMarker); pub struct SecretKeyIdMarker; impl IdPrefix for SecretKeyIdMarker { - const PREFIX: &'static str = "sk"; + // `skid_` rather than `sk_` to avoid muscle-memory collision with Stripe's + // `sk_live_…` / `sk_test_…` secret-key value format. + const PREFIX: &'static str = "skid"; const SCHEMA_NAME: &'static str = "SecretKeyId"; } diff --git a/server/src/core/public_id/public_id_tests.rs b/server/src/core/public_id/public_id_tests.rs index c7a2def..c121d0d 100644 --- a/server/src/core/public_id/public_id_tests.rs +++ b/server/src/core/public_id/public_id_tests.rs @@ -6,29 +6,29 @@ use super::{AffiliateId, Id, ParseIdError, SourceId, TenantId, WebhookId, HEX_LE fn from_object_id_stores_hex() { let oid = ObjectId::new(); let id: AffiliateId = AffiliateId::from_object_id(oid); - assert_eq!(id.as_hex(), &oid.to_hex()); + assert_eq!(id.as_hex(), oid.to_hex()); assert_eq!(id.as_hex().len(), HEX_LEN); } #[test] fn display_includes_prefix() { let oid = ObjectId::new(); - let id: AffiliateId = oid.into(); + let id = AffiliateId::from_object_id(oid); assert_eq!(format!("{id}"), format!("aff_{}", oid.to_hex())); } #[test] fn round_trip_to_object_id() { let oid = ObjectId::new(); - let id: WebhookId = oid.into(); - let back = id.to_object_id().unwrap(); + let id = WebhookId::from_object_id(oid); + let back = id.to_object_id(); assert_eq!(oid, back); } #[test] fn serialize_to_prefixed_string() { let oid = ObjectId::parse_str("665a1b2c3d4e5f6a7b8c9d0e").unwrap(); - let id: AffiliateId = oid.into(); + let id = AffiliateId::from_object_id(oid); let json = serde_json::to_string(&id).unwrap(); assert_eq!(json, "\"aff_665a1b2c3d4e5f6a7b8c9d0e\""); } @@ -91,7 +91,7 @@ fn fromstr_works() { let oid = ObjectId::new(); let s = format!("tnt_{}", oid.to_hex()); let id: TenantId = s.parse().unwrap(); - assert_eq!(id.as_hex(), &oid.to_hex()); + assert_eq!(id.as_hex(), oid.to_hex()); } #[test] @@ -126,27 +126,29 @@ fn schemars_schema_has_lowercase_hex_pattern() { #[test] fn equality_within_same_type() { let oid = ObjectId::new(); - let a: AffiliateId = oid.into(); - let b: AffiliateId = oid.into(); + let a = AffiliateId::from_object_id(oid); + let b = AffiliateId::from_object_id(oid); assert_eq!(a, b); } #[test] -fn ord_matches_hex_ord() { - let mut ids: Vec = (0..5).map(|_| AffiliateId::from(ObjectId::new())).collect(); - let mut hexes: Vec = ids.iter().map(|i| i.as_hex().to_string()).collect(); +fn ord_matches_objectid_ord() { + let mut ids: Vec = (0..5) + .map(|_| AffiliateId::from_object_id(ObjectId::new())) + .collect(); + let mut oids: Vec = ids.iter().map(|i| i.to_object_id()).collect(); ids.sort(); - hexes.sort(); - let after: Vec = ids.iter().map(|i| i.as_hex().to_string()).collect(); - assert_eq!(after, hexes); + oids.sort(); + let after: Vec = ids.iter().map(|i| i.to_object_id()).collect(); + assert_eq!(after, oids); } #[test] fn hash_consistency() { use std::collections::HashSet; let oid = ObjectId::new(); - let id: AffiliateId = oid.into(); - let clone = id.clone(); + let id = AffiliateId::from_object_id(oid); + let clone = id; let mut set = HashSet::new(); set.insert(id); assert!(set.contains(&clone)); @@ -170,7 +172,9 @@ fn bson_raw_serializes_as_native_object_id() { id: AffiliateId, } let oid = ObjectId::parse_str("665a1b2c3d4e5f6a7b8c9d0e").unwrap(); - let h = Holder { id: oid.into() }; + let h = Holder { + id: AffiliateId::from_object_id(oid), + }; let raw: RawDocumentBuf = mongodb::bson::to_raw_document_buf(&h).unwrap(); let value = raw.get("id").unwrap().unwrap(); match value { @@ -191,7 +195,7 @@ fn bson_raw_deserializes_from_native_object_id() { let doc = doc! { "id": oid }; let bytes = mongodb::bson::to_vec(&doc).unwrap(); let h: Holder = mongodb::bson::from_slice(&bytes).unwrap(); - assert_eq!(h.id.as_hex(), &oid.to_hex()); + assert_eq!(h.id.as_hex(), oid.to_hex()); } #[test] @@ -250,6 +254,18 @@ fn new_generates_distinct_ids() { assert_eq!(a.as_hex().len(), HEX_LEN); } +#[test] +fn no_blanket_from_object_id() { + // The blanket `impl From for Id

` was removed + // intentionally. Conversions must name the target type explicitly via + // `Id::from_object_id` so cross-resource ID mixups are caught at review. + let oid = ObjectId::new(); + let t = TenantId::from_object_id(oid); + let a = AffiliateId::from_object_id(oid); + assert_eq!(t.to_object_id(), a.to_object_id()); + // Cannot write `let _: TenantId = oid.into();` — that fails to compile. +} + // Compile-time: distinct marker types are not interchangeable. // fn _no_cross_assignment() { // let a: AffiliateId = ObjectId::new().into(); diff --git a/server/src/mcp/server.rs b/server/src/mcp/server.rs index 6e0bd0e..6c54686 100644 --- a/server/src/mcp/server.rs +++ b/server/src/mcp/server.rs @@ -85,7 +85,7 @@ impl RiftMcp { } Ok(AuthContext::for_secret_key( - key_doc.tenant_id, + crate::core::public_id::TenantId::from_object_id(key_doc.tenant_id), key_doc.id, Some(&KeyScope::Full), )) @@ -289,7 +289,7 @@ impl RiftMcp { Parameters(input): Parameters, Extension(parts): Extension, ) -> Result, String> { - let tenant_id = self.auth_context(&parts).await?.tenant_id; + let tenant_id = self.auth_context(&parts).await?.tenant_id.to_object_id(); let repo = self .conversions_repo .as_ref() @@ -336,7 +336,7 @@ impl RiftMcp { Parameters(_input): Parameters, Extension(parts): Extension, ) -> Result, String> { - let tenant_id = self.auth_context(&parts).await?.tenant_id; + let tenant_id = self.auth_context(&parts).await?.tenant_id.to_object_id(); let repo = self .conversions_repo .as_ref() diff --git a/server/src/services/affiliates/service.rs b/server/src/services/affiliates/service.rs index 59c6446..afd6b6c 100644 --- a/server/src/services/affiliates/service.rs +++ b/server/src/services/affiliates/service.rs @@ -49,7 +49,8 @@ impl AffiliatesService { validate_partner_key(&partner_key)?; if let Some(q) = &self.quota { - q.check(&ctx.tenant_id, Resource::CreateAffiliate).await?; + q.check(ctx.tenant_id.as_object_id(), Resource::CreateAffiliate) + .await?; } // Pre-check uniqueness for a clean error before hitting the DB write. @@ -57,7 +58,7 @@ impl AffiliatesService { // backstop — see the E11000 catch below. if self .repo - .find_by_partner_key(&ctx.tenant_id.into(), &partner_key) + .find_by_partner_key(&ctx.tenant_id, &partner_key) .await .map_err(AffiliateError::Internal)? .is_some() @@ -68,7 +69,7 @@ impl AffiliatesService { let now = DateTime::now(); let affiliate = Affiliate { id: AffiliateId::new(), - tenant_id: ctx.tenant_id.into(), + tenant_id: ctx.tenant_id, name, partner_key: partner_key.clone(), status: AffiliateStatus::Active, @@ -94,7 +95,7 @@ impl AffiliatesService { affiliate_id: AffiliateId, ) -> Result { self.repo - .get_by_id(&ctx.tenant_id.into(), &affiliate_id) + .get_by_id(&ctx.tenant_id, &affiliate_id) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound) @@ -106,7 +107,7 @@ impl AffiliatesService { ctx: &AuthContext, ) -> Result, AffiliateError> { self.repo - .list_by_tenant(&ctx.tenant_id.into()) + .list_by_tenant(&ctx.tenant_id) .await .map_err(AffiliateError::Internal) } @@ -129,7 +130,7 @@ impl AffiliatesService { let updated = self .repo .update_affiliate( - &ctx.tenant_id.into(), + &ctx.tenant_id, &affiliate_id, req.name.as_deref(), req.status, @@ -145,7 +146,7 @@ impl AffiliatesService { // Re-fetch so the response carries the persisted values (including // the new updated_at). self.repo - .get_by_id(&ctx.tenant_id.into(), &affiliate_id) + .get_by_id(&ctx.tenant_id, &affiliate_id) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound) @@ -157,21 +158,23 @@ impl AffiliatesService { pub async fn mint_credential( &self, ctx: &AuthContext, - affiliate_id: ObjectId, + affiliate_id: AffiliateId, + // `created_by` is still ObjectId until users/secret_keys migrate. created_by: ObjectId, ) -> Result { // Affiliate must exist in this tenant. self.repo - .get_by_id(&ctx.tenant_id.into(), &affiliate_id.into()) + .get_by_id(&ctx.tenant_id, &affiliate_id) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound)?; // Per-affiliate cap. Counted at the repo level via the scope filter // so a compromised tenant key can't spam unbounded credentials. + let aff_oid = affiliate_id.to_object_id(); let existing = self .secret_keys_repo - .list_by_tenant_and_affiliate(&ctx.tenant_id, &affiliate_id) + .list_by_tenant_and_affiliate(ctx.tenant_id.as_object_id(), &aff_oid) .await .map_err(AffiliateError::Internal)?; if existing.len() >= MAX_CREDENTIALS_PER_AFFILIATE { @@ -180,16 +183,18 @@ impl AffiliatesService { let created_key = mint_scoped( self.secret_keys_repo.as_ref(), - ctx.tenant_id, + ctx.tenant_id.to_object_id(), created_by, - KeyScope::Affiliate { affiliate_id }, + KeyScope::Affiliate { + affiliate_id: aff_oid, + }, ) .await .map_err(AffiliateError::Internal)?; Ok(MintedCredential { created_key, - affiliate_id: affiliate_id.into(), + affiliate_id, }) } @@ -200,17 +205,20 @@ impl AffiliatesService { pub async fn list_credentials( &self, ctx: &AuthContext, - affiliate_id: ObjectId, + affiliate_id: AffiliateId, ) -> Result, AffiliateError> { // Affiliate must exist (404 vs empty list — different semantics). self.repo - .get_by_id(&ctx.tenant_id.into(), &affiliate_id.into()) + .get_by_id(&ctx.tenant_id, &affiliate_id) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound)?; self.secret_keys_repo - .list_by_tenant_and_affiliate(&ctx.tenant_id, &affiliate_id) + .list_by_tenant_and_affiliate( + ctx.tenant_id.as_object_id(), + &affiliate_id.to_object_id(), + ) .await .map_err(AffiliateError::Internal) } @@ -221,19 +229,24 @@ impl AffiliatesService { pub async fn revoke_credential( &self, ctx: &AuthContext, - affiliate_id: ObjectId, + affiliate_id: AffiliateId, + // `key_id` is still ObjectId — secret_keys hasn't migrated yet. key_id: ObjectId, ) -> Result<(), AffiliateError> { // Surface affiliate-not-found distinctly from credential-not-found. self.repo - .get_by_id(&ctx.tenant_id.into(), &affiliate_id.into()) + .get_by_id(&ctx.tenant_id, &affiliate_id) .await .map_err(AffiliateError::Internal)? .ok_or(AffiliateError::NotFound)?; let deleted = self .secret_keys_repo - .delete_affiliate_credential(&ctx.tenant_id, &affiliate_id, &key_id) + .delete_affiliate_credential( + ctx.tenant_id.as_object_id(), + &affiliate_id.to_object_id(), + &key_id, + ) .await .map_err(AffiliateError::Internal)?; @@ -257,7 +270,7 @@ impl AffiliatesService { ) -> Result<(), AffiliateError> { let deleted = self .repo - .delete_affiliate(&ctx.tenant_id.into(), &affiliate_id) + .delete_affiliate(&ctx.tenant_id, &affiliate_id) .await .map_err(AffiliateError::Internal)?; diff --git a/server/src/services/analytics/service.rs b/server/src/services/analytics/service.rs index 53e07b9..5725113 100644 --- a/server/src/services/analytics/service.rs +++ b/server/src/services/analytics/service.rs @@ -54,7 +54,7 @@ impl AnalyticsService { return Err(AnalyticsError::InvalidDateRange); } - let tenant_id = &ctx.tenant_id; + let tenant_id = ctx.tenant_id.as_object_id(); // 2. Clicks — credit-independent. Direct count over click_events. let clicks = self diff --git a/server/src/services/auth/permissions/context.rs b/server/src/services/auth/permissions/context.rs index 81609f2..8521972 100644 --- a/server/src/services/auth/permissions/context.rs +++ b/server/src/services/auth/permissions/context.rs @@ -2,6 +2,7 @@ //! helpers. Implementation file; `pub` data types live in `models.rs`. use super::models::{AuthContext, AuthzError, Permission, Principal, ResourceScope, Scopes}; +use crate::core::public_id::{TenantId, UserId}; use crate::services::auth::secret_keys::repo::KeyScope; use mongodb::bson::oid::ObjectId; use std::collections::BTreeSet; @@ -9,7 +10,7 @@ use std::collections::BTreeSet; impl AuthContext { /// Build context for a session-authenticated request. Sessions are always /// full tenant access — there's no affiliate-scoped human in Phase 1. - pub fn for_session(tenant_id: ObjectId, user_id: ObjectId, session_id: ObjectId) -> Self { + pub fn for_session(tenant_id: TenantId, user_id: UserId, session_id: ObjectId) -> Self { Self { tenant_id, principal: Principal::User { @@ -25,7 +26,7 @@ impl AuthContext { /// `None` for grandfathered pre-migration rows — treated as `Full`, same /// rule as `services/auth/scope::require_full`. pub fn for_secret_key( - tenant_id: ObjectId, + tenant_id: TenantId, key_id: ObjectId, key_scope: Option<&KeyScope>, ) -> Self { @@ -34,7 +35,9 @@ impl AuthContext { Some(KeyScope::Affiliate { affiliate_id }) => ( Scopes::affiliate_partner(), ResourceScope::Affiliate { - affiliate_id: *affiliate_id, + affiliate_id: crate::core::public_id::AffiliateId::from_object_id( + *affiliate_id, + ), }, ), }; diff --git a/server/src/services/auth/permissions/context_tests.rs b/server/src/services/auth/permissions/context_tests.rs index 571e761..6cc53bb 100644 --- a/server/src/services/auth/permissions/context_tests.rs +++ b/server/src/services/auth/permissions/context_tests.rs @@ -1,9 +1,10 @@ use super::super::models::{AuthContext, AuthzError, Permission, Principal, ResourceScope, Scopes}; +use crate::core::public_id::{TenantId, UserId}; use crate::services::auth::secret_keys::repo::KeyScope; use mongodb::bson::oid::ObjectId; fn user_ctx() -> AuthContext { - AuthContext::for_session(ObjectId::new(), ObjectId::new(), ObjectId::new()) + AuthContext::for_session(TenantId::new(), UserId::new(), ObjectId::new()) } #[test] @@ -16,13 +17,13 @@ fn session_has_full_scope() { #[test] fn secret_key_full_has_full_scope() { - let ctx = AuthContext::for_secret_key(ObjectId::new(), ObjectId::new(), Some(&KeyScope::Full)); + let ctx = AuthContext::for_secret_key(TenantId::new(), ObjectId::new(), Some(&KeyScope::Full)); assert!(ctx.require(Permission::AffiliatesWrite).is_ok()); } #[test] fn secret_key_missing_scope_grandfathered_to_full() { - let ctx = AuthContext::for_secret_key(ObjectId::new(), ObjectId::new(), None); + let ctx = AuthContext::for_secret_key(TenantId::new(), ObjectId::new(), None); assert!(ctx.require(Permission::WebhooksWrite).is_ok()); } @@ -30,7 +31,7 @@ fn secret_key_missing_scope_grandfathered_to_full() { fn secret_key_affiliate_has_only_links_scope() { let affiliate_id = ObjectId::new(); let ctx = AuthContext::for_secret_key( - ObjectId::new(), + TenantId::new(), ObjectId::new(), Some(&KeyScope::Affiliate { affiliate_id }), ); @@ -40,15 +41,16 @@ fn secret_key_affiliate_has_only_links_scope() { ctx.require(Permission::AffiliatesWrite).unwrap_err(), AuthzError::MissingPermission(Permission::AffiliatesWrite) ); - assert!( - matches!(ctx.resource_scope, ResourceScope::Affiliate { affiliate_id: a } if a == affiliate_id) - ); + assert!(matches!( + ctx.resource_scope, + ResourceScope::Affiliate { affiliate_id: a } if a.to_object_id() == affiliate_id + )); } #[test] fn require_any_succeeds_if_one_matches() { let ctx = AuthContext::for_secret_key( - ObjectId::new(), + TenantId::new(), ObjectId::new(), Some(&KeyScope::Affiliate { affiliate_id: ObjectId::new(), @@ -62,7 +64,7 @@ fn require_any_succeeds_if_one_matches() { #[test] fn require_any_fails_when_none_match() { let ctx = AuthContext::for_secret_key( - ObjectId::new(), + TenantId::new(), ObjectId::new(), Some(&KeyScope::Affiliate { affiliate_id: ObjectId::new(), @@ -79,7 +81,7 @@ fn principal_carries_correct_kind() { let session = user_ctx(); assert!(matches!(session.principal, Principal::User { .. })); - let key = AuthContext::for_secret_key(ObjectId::new(), ObjectId::new(), Some(&KeyScope::Full)); + let key = AuthContext::for_secret_key(TenantId::new(), ObjectId::new(), Some(&KeyScope::Full)); assert!(matches!(key.principal, Principal::SecretKey { .. })); } diff --git a/server/src/services/auth/permissions/models.rs b/server/src/services/auth/permissions/models.rs index a40b847..998a522 100644 --- a/server/src/services/auth/permissions/models.rs +++ b/server/src/services/auth/permissions/models.rs @@ -4,6 +4,8 @@ use mongodb::bson::oid::ObjectId; use std::collections::BTreeSet; use std::fmt; +use crate::core::public_id::{AffiliateId, TenantId, UserId}; + /// Closed set of operation types a caller can be authorized for. Wire /// representation is `:` (see `to_wire_str`) — used in /// 403 error bodies and (future) OpenAPI security scope strings. @@ -65,14 +67,15 @@ pub struct Scopes(pub(super) BTreeSet); /// Who is making the call. The orthogonal "what resources can they touch?" /// dimension lives in `ResourceScope`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Principal { /// Browser/dashboard session. User { - user_id: ObjectId, + user_id: UserId, + /// Session id — still an ObjectId until the sessions resource migrates. session_id: ObjectId, }, - /// `rl_live_…` secret key. + /// `rl_live_…` secret key. `key_id` stays ObjectId until secret_keys migrates. SecretKey { key_id: ObjectId }, } @@ -81,10 +84,10 @@ pub enum Principal { /// `LinksWrite` but only on its own affiliate's links. Instance-level /// filtering lives in the repos (`WHERE tenant_id = ? AND affiliate_id = ?`), /// not in scope checks. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ResourceScope { Tenant, - Affiliate { affiliate_id: ObjectId }, + Affiliate { affiliate_id: AffiliateId }, } /// Unified identity injected into request extensions by the auth middleware. @@ -92,7 +95,7 @@ pub enum ResourceScope { /// `#[requires(...)]` proc-macro injects it for them). #[derive(Debug, Clone)] pub struct AuthContext { - pub tenant_id: ObjectId, + pub tenant_id: TenantId, pub principal: Principal, pub permissions: Scopes, pub resource_scope: ResourceScope, diff --git a/server/src/services/auth/secret_keys/service.rs b/server/src/services/auth/secret_keys/service.rs index acc689c..a861cf4 100644 --- a/server/src/services/auth/secret_keys/service.rs +++ b/server/src/services/auth/secret_keys/service.rs @@ -99,7 +99,7 @@ impl SecretKeysService { // Permission check: target email must be a verified member of this tenant. let user = self .users_repo - .find_by_tenant_and_email(&ctx.tenant_id, email) + .find_by_tenant_and_email(ctx.tenant_id.as_object_id(), email) .await .map_err(SecretKeyError::Internal)? .ok_or(SecretKeyError::UserNotMember)?; @@ -113,7 +113,7 @@ impl SecretKeysService { // Key limit. let count = self .sk_repo - .count_by_tenant(&ctx.tenant_id) + .count_by_tenant(ctx.tenant_id.as_object_id()) .await .map_err(SecretKeyError::Internal)?; if count >= 5 { @@ -208,7 +208,7 @@ impl SecretKeysService { // Belt-and-suspenders: the token is bound to a tenant; the // HTTP caller also claims a tenant via API key. They must // match, otherwise someone's crossing sessions. - if meta_tenant != ctx.tenant_id { + if meta_tenant != *ctx.tenant_id.as_object_id() { return Err(SecretKeyError::InvalidCode); } @@ -225,7 +225,7 @@ impl SecretKeysService { pub async fn list(&self, ctx: &AuthContext) -> Result, SecretKeyError> { let docs = self .sk_repo - .list_by_tenant(&ctx.tenant_id) + .list_by_tenant(ctx.tenant_id.as_object_id()) .await .map_err(SecretKeyError::Internal)?; @@ -260,7 +260,7 @@ impl SecretKeysService { let count = self .sk_repo - .count_by_tenant(&ctx.tenant_id) + .count_by_tenant(ctx.tenant_id.as_object_id()) .await .map_err(SecretKeyError::Internal)?; @@ -270,7 +270,7 @@ impl SecretKeysService { let deleted = self .sk_repo - .delete_key(&ctx.tenant_id, &key_id) + .delete_key(ctx.tenant_id.as_object_id(), &key_id) .await .map_err(SecretKeyError::Internal)?; @@ -298,15 +298,19 @@ impl SecretKeysService { let count = self .sk_repo - .count_by_tenant(&ctx.tenant_id) + .count_by_tenant(ctx.tenant_id.as_object_id()) .await .map_err(SecretKeyError::Internal)?; if count >= 5 { return Err(SecretKeyError::KeyLimit); } - mint_for_tenant(&*self.sk_repo, ctx.tenant_id, user_id) - .await - .map_err(SecretKeyError::Internal) + mint_for_tenant( + &*self.sk_repo, + ctx.tenant_id.to_object_id(), + user_id.to_object_id(), + ) + .await + .map_err(SecretKeyError::Internal) } } diff --git a/server/src/services/auth/users/service.rs b/server/src/services/auth/users/service.rs index 972bfe9..3ad5d2d 100644 --- a/server/src/services/auth/users/service.rs +++ b/server/src/services/auth/users/service.rs @@ -130,7 +130,7 @@ impl UsersService { if self .users_repo - .find_by_tenant_and_email(&ctx.tenant_id, &email) + .find_by_tenant_and_email(ctx.tenant_id.as_object_id(), &email) .await .map_err(UserError::Internal)? .is_some() @@ -140,13 +140,14 @@ impl UsersService { // Service-layer quota enforcement (applies to every transport). if let Some(q) = &self.quota { - q.check(&ctx.tenant_id, Resource::InviteTeamMember).await?; + q.check(ctx.tenant_id.as_object_id(), Resource::InviteTeamMember) + .await?; } let user_id = ObjectId::new(); let user_doc = UserDoc { id: Some(user_id), - tenant_id: ctx.tenant_id, + tenant_id: ctx.tenant_id.to_object_id(), email: email.clone(), verified: false, is_owner: false, @@ -200,7 +201,7 @@ impl UsersService { pub async fn list(&self, ctx: &AuthContext) -> Result, UserError> { let docs = self .users_repo - .list_by_tenant(&ctx.tenant_id) + .list_by_tenant(ctx.tenant_id.as_object_id()) .await .map_err(UserError::Internal)?; @@ -221,7 +222,7 @@ impl UsersService { pub async fn delete(&self, ctx: &AuthContext, user_id: ObjectId) -> Result<(), UserError> { let count = self .users_repo - .count_verified_by_tenant(&ctx.tenant_id) + .count_verified_by_tenant(ctx.tenant_id.as_object_id()) .await .map_err(UserError::Internal)?; @@ -231,7 +232,7 @@ impl UsersService { let deleted = self .users_repo - .delete(&ctx.tenant_id, &user_id) + .delete(ctx.tenant_id.as_object_id(), &user_id) .await .map_err(UserError::Internal)?; diff --git a/server/src/services/billing/repos/resource_counts_adapter.rs b/server/src/services/billing/repos/resource_counts_adapter.rs index d604e4f..e548352 100644 --- a/server/src/services/billing/repos/resource_counts_adapter.rs +++ b/server/src/services/billing/repos/resource_counts_adapter.rs @@ -35,7 +35,11 @@ impl ResourceCounts for RepoResourceCounts { .map(|n| n as u64), Resource::CreateWebhook => self.webhooks.count_by_tenant(tenant_id).await, Resource::CreateAffiliate => { - self.affiliates.count_by_tenant(&(*tenant_id).into()).await + self.affiliates + .count_by_tenant(&crate::core::public_id::TenantId::from_object_id( + *tenant_id, + )) + .await } // TrackEvent uses the atomic counter path, not ResourceCounts. Resource::TrackEvent => Ok(0), diff --git a/server/src/services/billing/service.rs b/server/src/services/billing/service.rs index 25d7ff7..ad6fcc5 100644 --- a/server/src/services/billing/service.rs +++ b/server/src/services/billing/service.rs @@ -44,7 +44,7 @@ impl BillingService { pub async fn status(&self, ctx: &AuthContext) -> Result { let tenant = self .tenants_repo - .find_by_id(&ctx.tenant_id) + .find_by_id(ctx.tenant_id.as_object_id()) .await .map_err(BillingError::Internal)? .ok_or(BillingError::TenantNotFound)?; diff --git a/server/src/services/billing/service_tests.rs b/server/src/services/billing/service_tests.rs index fd35866..2065edd 100644 --- a/server/src/services/billing/service_tests.rs +++ b/server/src/services/billing/service_tests.rs @@ -8,7 +8,11 @@ use async_trait::async_trait; use std::sync::Mutex; fn full_ctx_for(tenant_id: ObjectId) -> AuthContext { - AuthContext::for_secret_key(tenant_id, ObjectId::new(), Some(&KeyScope::Full)) + AuthContext::for_secret_key( + crate::core::public_id::TenantId::from_object_id(tenant_id), + ObjectId::new(), + Some(&KeyScope::Full), + ) } #[derive(Default)] diff --git a/server/src/services/domains/service.rs b/server/src/services/domains/service.rs index 723de33..53e42be 100644 --- a/server/src/services/domains/service.rs +++ b/server/src/services/domains/service.rs @@ -35,11 +35,16 @@ impl DomainsService { role: DomainRole, ) -> Result { if let Some(q) = &self.quota { - q.check(&ctx.tenant_id, Resource::CreateDomain).await?; + q.check(ctx.tenant_id.as_object_id(), Resource::CreateDomain) + .await?; } if role == DomainRole::Alternate { - if let Ok(Some(_)) = self.repo.find_alternate_by_tenant(&ctx.tenant_id).await { + if let Ok(Some(_)) = self + .repo + .find_alternate_by_tenant(ctx.tenant_id.as_object_id()) + .await + { return Err(DomainError::AlternateLimit); } } @@ -57,7 +62,12 @@ impl DomainsService { match self .repo - .create_domain(ctx.tenant_id, domain, verification_token, role) + .create_domain( + ctx.tenant_id.to_object_id(), + domain, + verification_token, + role, + ) .await { Ok(d) => Ok(d), diff --git a/server/src/services/links/service.rs b/server/src/services/links/service.rs index 6b5e715..73619ed 100644 --- a/server/src/services/links/service.rs +++ b/server/src/services/links/service.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use super::models::*; use super::repo::LinksRepository; +use crate::core::public_id::{AffiliateId, TenantId}; use crate::core::threat_feed::ThreatFeed; use crate::core::validation; use crate::services::affiliates::repo::AffiliatesRepository; @@ -354,7 +355,7 @@ impl LinksService { ctx: &AuthContext, req: CreateLinkRequest, ) -> Result { - let tenant_id = ctx.tenant_id; + let tenant_id = ctx.tenant_id.to_object_id(); // Quota enforcement lives here (service layer) so MCP tool invocations // and HTTP route handlers both hit the same choke point. CLAUDE.md // codifies this rule — see "Quota enforcement" section there. @@ -478,7 +479,7 @@ impl LinksService { ctx: &AuthContext, req: BulkCreateLinksRequest, ) -> Result { - let tenant_id = ctx.tenant_id; + let tenant_id = ctx.tenant_id.to_object_id(); // 1. Mode — exactly one of custom_ids / count. let mode_ids = req.custom_ids.as_deref(); let mode_count = req.count; @@ -681,7 +682,7 @@ impl LinksService { ) -> Result { let link = self .links_repo - .find_link_by_tenant_and_id(&ctx.tenant_id, link_id) + .find_link_by_tenant_and_id(ctx.tenant_id.as_object_id(), link_id) .await .map_err(LinkError::Internal)? .ok_or(LinkError::NotFound)?; @@ -689,8 +690,8 @@ impl LinksService { // Affiliate-scoped credentials can only read their own affiliate's // links. Return NotFound (not Forbidden) so the existence of links // belonging to other affiliates isn't disclosed. - if let ResourceScope::Affiliate { affiliate_id } = ctx.resource_scope { - if link.affiliate_id != Some(affiliate_id) { + if let ResourceScope::Affiliate { affiliate_id } = &ctx.resource_scope { + if link.affiliate_id != Some(affiliate_id.to_object_id()) { return Err(LinkError::NotFound); } } @@ -706,7 +707,7 @@ impl LinksService { limit: Option, cursor: Option, ) -> Result { - let tenant_id = &ctx.tenant_id; + let tenant_id = ctx.tenant_id.as_object_id(); let limit = limit.unwrap_or(50).clamp(1, 100); let cursor_id = cursor.and_then(|c| ObjectId::parse_str(&c).ok()); @@ -750,7 +751,7 @@ impl LinksService { link_id: &str, req: UpdateLinkRequest, ) -> Result { - let tenant_id = &ctx.tenant_id; + let tenant_id = ctx.tenant_id.as_object_id(); // Flatten Option> to Option<&str> for validation. let ios_dl = req.ios_deep_link.as_ref().and_then(|v| v.as_deref()); let android_dl = req.android_deep_link.as_ref().and_then(|v| v.as_deref()); @@ -893,7 +894,7 @@ impl LinksService { pub async fn delete_link(&self, ctx: &AuthContext, link_id: &str) -> Result<(), LinkError> { let deleted = self .links_repo - .delete_link(&ctx.tenant_id, link_id) + .delete_link(ctx.tenant_id.as_object_id(), link_id) .await .map_err(|e| { tracing::error!("Failed to delete link: {e}"); @@ -955,9 +956,13 @@ impl LinksService { ) -> Result, LinkError> { match (resource_scope, requested) { // Affiliate-scoped credential — server pins to scope; reject mismatch. - (ResourceScope::Affiliate { affiliate_id }, None) => Ok(Some(*affiliate_id)), - (ResourceScope::Affiliate { affiliate_id }, Some(req)) if req == *affiliate_id => { - Ok(Some(*affiliate_id)) + (ResourceScope::Affiliate { affiliate_id }, None) => { + Ok(Some(affiliate_id.to_object_id())) + } + (ResourceScope::Affiliate { affiliate_id }, Some(req)) + if req == affiliate_id.to_object_id() => + { + Ok(Some(affiliate_id.to_object_id())) } (ResourceScope::Affiliate { .. }, Some(_)) => Err(LinkError::AffiliateScopeMismatch), @@ -968,10 +973,13 @@ impl LinksService { .affiliates_repo .as_ref() .ok_or(LinkError::AffiliateNotFound)?; - repo.get_by_id(&(*tenant_id).into(), &req.into()) - .await - .map_err(LinkError::Internal)? - .ok_or(LinkError::AffiliateNotFound)?; + repo.get_by_id( + &TenantId::from_object_id(*tenant_id), + &AffiliateId::from_object_id(req), + ) + .await + .map_err(LinkError::Internal)? + .ok_or(LinkError::AffiliateNotFound)?; Ok(Some(req)) } diff --git a/server/src/services/links/service_tests.rs b/server/src/services/links/service_tests.rs index b693d4e..21008f0 100644 --- a/server/src/services/links/service_tests.rs +++ b/server/src/services/links/service_tests.rs @@ -13,7 +13,11 @@ use std::sync::Mutex; /// every links-service test should land on the happy authorization path /// so the assertion focuses on business-logic behavior, not the gate. fn ctx(tenant_id: ObjectId) -> AuthContext { - AuthContext::for_secret_key(tenant_id, ObjectId::new(), Some(&KeyScope::Full)) + AuthContext::for_secret_key( + crate::core::public_id::TenantId::from_object_id(tenant_id), + ObjectId::new(), + Some(&KeyScope::Full), + ) } #[test] diff --git a/server/src/services/webhooks/service.rs b/server/src/services/webhooks/service.rs index 24078b0..a1fe834 100644 --- a/server/src/services/webhooks/service.rs +++ b/server/src/services/webhooks/service.rs @@ -36,12 +36,13 @@ impl WebhooksService { created_at: mongodb::bson::DateTime, ) -> Result { if let Some(q) = &self.quota { - q.check(&ctx.tenant_id, Resource::CreateWebhook).await?; + q.check(ctx.tenant_id.as_object_id(), Resource::CreateWebhook) + .await?; } let webhook = Webhook { id, - tenant_id: ctx.tenant_id, + tenant_id: ctx.tenant_id.to_object_id(), url, secret, events, From 4a66684999e3a5ab5d8bd2934fa4a5c50c7213fa Mon Sep 17 00:00:00 2001 From: Andrei Terentiev Date: Fri, 29 May 2026 12:19:34 -0500 Subject: [PATCH 5/5] Migrate remaining resources to typed Id

(#156) (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate domains, apps, conversions to typed Id

- domains: `Domain { id: DomainId, tenant_id: TenantId }`. Routes use `Path` flow. `services/domains/models.rs` off the backlog. - apps: `App { id: AppId, tenant_id: TenantId }`. `AppDetail.id: AppId`. Routes use `Path`. `resolve_tenant_from_host` returns `Option`. `apps/models.rs` and `api/apps/routes.rs` off the backlog. - conversions: `Source { id: SourceId, tenant_id: TenantId }`, `ConversionEvent.id: Option`, `ConversionMeta { tenant_id: TenantId, source_id: SourceId }`, `SourceDetail.id: SourceId`, `CreateSourceResponse.id: SourceId`. MCP `CreateSourceOutput` / `SourceSummary` typed too. `ConversionDedup` stays on backlog (internal-only docu). Cross-resource bridges: - `links/routes.rs` calls `domain.tenant_id.as_object_id()` when passing to repos still on backlog. - `apps/routes.rs` passes `tenant_id.as_object_id()` to apps repo. - `conversions/service.rs` bridges to ObjectId at the ingest boundary (tenant_id, source_id) since the time-series repo still uses ObjectId. Tests: - `links/service_tests.rs` Domain mock uses typed Id

. - `conversions/parsers_tests.rs` source factory uses typed Id

. Co-Authored-By: Claude Opus 4.7 (1M context) * Migrate webhooks, app_users, install_events to typed Id

- webhooks: `Webhook { id: WebhookId, tenant_id: TenantId }`, `WebhookDetail.id: WebhookId`, `CreateWebhookResponse.id: WebhookId`. Service takes `WebhookId` for `create_webhook`. Off the backlog. - app_users: added `AppUserIdMarker` (prefix `appusr`). `AppUserDoc` uses `Option` / `TenantId`. Off the backlog. - install_events: added `InstallEventIdMarker` (prefix `iev`). `InstallEvent` uses `Option` / `TenantId`. Off the backlog. Also added `LinkInternalIdMarker` (prefix `lnk`) for the upcoming links model migration — distinct from the public `link_id` vanity slug. Bridges: - `webhooks/routes.rs`: comparisons use `w.id.to_object_id()`. - `install_events/repo.rs`: bridges `*tenant_id` to `TenantId::from_object_id`. Co-Authored-By: Claude Opus 4.7 (1M context) * Migrate auth/{sessions,tenants,users,oauth}/models.rs + add session markers New markers: - `AuthSessionIdMarker` (`sess_`) — user auth sessions - `OAuthSessionIdMarker` (`osess_`) — OAuth flow state Models migrated to typed Id

: - `SessionDoc { id: AuthSessionId, user_id: UserId, tenant_id: TenantId }` - `ResolvedSession`, `SignInOutcome` use typed IDs - `TenantDoc.id: Option` - `UserDoc { id: Option, tenant_id: TenantId }` - `UserDetail`, `InviteResult`, `VerifyResult` use typed IDs - `OauthCallbackOutcome { user_id: UserId, tenant_id: TenantId }` Axum extension unification: - `api::auth::models::SessionId` is now `pub use crate::core::public_id::AuthSessionId as SessionId` — same trick as Tenant/User: one type, end-to-end. AuthContext + Principal: - `Principal::User { user_id: UserId, session_id: AuthSessionId }` - `AuthContext::for_session` takes typed args Bridges: - `tenants/service.rs::create_blank` mints `TenantId::new()` internally, returns `ObjectId` to existing callers (one-line bridge until call sites migrate). - `users_service::create_tenant_with_verified_owner` still returns ObjectIds; sessions and OAuth services bridge with `from_object_id` at the call site. - `stripe_webhook.rs::try_resolve_tenant` `.map(|t| id.to_object_id())` bridges. - Test mocks updated to mirror typed IDs. Co-Authored-By: Claude Opus 4.7 (1M context) * Cleanup unused imports + remove sessions/oauth services from backlog Lib + lib tests pass (205 tests, including arch tests). Integration tests under `tests/` need follow-up updates to common mocks for the typed IDs — they reference patterns that changed in the auth/sessions migration. Co-Authored-By: Claude Opus 4.7 (1M context) * Migrate affiliate credential routes + link CreateRequest affiliate_id - `api/affiliates/routes.rs`: credential route handlers use `Path` instead of `Path` + manual `ObjectId::parse_str`. - `api/webhooks/routes.rs`: `delete_webhook` and `patch_webhook` use `Path`. - `services/links/models.rs`: `CreateLinkRequest.affiliate_id`, `BulkLinkTemplate.affiliate_id`, `LinkDetail.affiliate_id` typed as `Option`. Drops the custom `serialize_opt_object_id_as_hex` helper for affiliate_id — `Id

::serialize` is format-conditional. - `services/links/service.rs`: bridges request `Option` to storage `Option` via `.map(|a| a.to_object_id())`. The response builder maps the other direction. - Integration test fixtures updated to use the prefixed format (`aff_`, `wh_`) via `Id

::new().to_string()` / `Id

::parse(&s)`. Tests: 205 lib + 179 doc + 147 integration all pass. Backlog now 30 (was 44 at start of #175). Co-Authored-By: Claude Opus 4.7 (1M context) * Migrate secret_keys + publishable_keys models + their delete routes - secret_keys/models.rs: `SecretKeyDoc { id: SecretKeyId, tenant_id: TenantId, created_by: UserId }`. `CreatedKey.id`, `KeyDetail.{id,created_by}` typed. - publishable_keys/models.rs: `SdkKeyDoc { id: PublishableKeyId, tenant_id: TenantId }`. - secret_keys delete + sdk_keys revoke + affiliate credential revoke routes all use `Path` / `Path` (no more manual parse_str). - mint_scoped (secret_keys::service) constructs via `SecretKeyId::new()`. - Middleware `find_secret_key_doc` helper returns ObjectIds via `to_object_id()` at the boundary. - Test fixtures + mocks updated. 205 lib + 179 doc + 147 integration tests pass. Backlog now 28 (was 30). Co-Authored-By: Claude Opus 4.7 (1M context) * Migrate links.Link, conversions.ConversionDedup, billing/auth/usage models - `services/links/models.rs::Link`: `id: LinkInternalId`, `tenant_id: TenantId`, `affiliate_id: Option` typed end-to-end. - `services/auth/usage/models.rs::UsageDoc`: typed `Option` / `Option` (new `UsageRowIdMarker` with prefix `usage`). - `services/conversions/models.rs::ConversionDedup`: `id: ConversionDedupId`, `tenant_id: TenantId` (new `ConversionDedupIdMarker` with prefix `cdedup`). - `services/auth/publishable_keys/models.rs::SdkKeyDoc` typed too. - `services/billing/models.rs::EventCounterDoc.tenant_id` typed. - Link list cursor parses via `LinkInternalId::parse` then `.to_object_id()`. - `api/lifecycle/routes.rs` builds `IdentifyEventPayload.tenant_id` as `TenantId::from_object_id(*tenant_id).to_string()` so the webhook wire format is the prefixed string. - Many test fixtures + repo internals updated with `from_object_id` bridges. - 205 lib + 179 doc + 147 integration tests all pass. Backlog: 24 (was 28). Co-Authored-By: Claude Opus 4.7 (1M context) * Drop analytics + publishable_keys routes + auth/secret_keys/lifecycle/webhooks routes from backlog * Migrate KeyScope::Affiliate.affiliate_id to AffiliateId * Migrate AuthKeyId to wrap SecretKeyId + cached webhook lookup uses TenantId * Migrate Principal::SecretKey.key_id + tenants::create_blank to typed * Migrate links/routes lookup_tenant_domain + auth/users/routes + permissions/models * Migrate sessions/service.revoke + conversions/routes path types * Migrate affiliates credential service methods to typed Ids * Migrate auth/users service: delete + create_tenant_with_verified_owner typed * Middleware validate_api_key returns typed (TenantId, SecretKeyId) * Migrate stripe_webhook helpers to TenantId * Migrate secret_keys service to typed TenantId/UserId/SecretKeyId * Migrate QuotaChecker trait to typed TenantId * Migrate conversions service to typed TenantId/SourceId * Migrate TierResolver trait + BillingService to typed TenantId * Migrate links models (ClickMeta, AttributionEventMeta, CreateLinkInput) to typed IDs * Migrate links service to typed TenantId/AffiliateId — backlog drained to zero * Docs: update prefixed-ID examples in OpenAPI + CLAUDE.md/AGENTS.md --------- Co-authored-by: Claude Opus 4.7 (1M context) --- AGENTS.md | 6 +- CLAUDE.md | 2 +- server/src/api/affiliates/routes.rs | 55 +++------ server/src/api/apps/routes.rs | 28 ++--- server/src/api/auth/middleware.rs | 49 ++++---- server/src/api/auth/models.rs | 26 ++-- .../src/api/auth/publishable_keys/routes.rs | 26 ++-- server/src/api/auth/secret_keys/models.rs | 6 +- server/src/api/auth/secret_keys/routes.rs | 19 +-- server/src/api/auth/sessions/models.rs | 4 +- server/src/api/auth/sessions/routes.rs | 12 +- server/src/api/auth/users/models.rs | 6 +- server/src/api/auth/users/routes.rs | 17 +-- server/src/api/billing/stripe_webhook.rs | 18 +-- server/src/api/conversions/routes.rs | 39 ++---- server/src/api/domains/routes.rs | 2 +- server/src/api/lifecycle/routes.rs | 18 +-- server/src/api/links/routes.rs | 15 ++- server/src/api/webhooks/routes.rs | 37 ++---- server/src/architecture_tests.rs | 47 +------- server/src/core/public_id/mod.rs | 44 ++++++- server/src/core/public_id/models.rs | 8 +- server/src/mcp/models.rs | 6 +- server/src/mcp/server.rs | 6 +- server/src/services/affiliates/models.rs | 4 +- server/src/services/affiliates/service.rs | 20 ++- server/src/services/analytics/service.rs | 16 ++- server/src/services/app_users/models.rs | 8 +- server/src/services/apps/models.rs | 11 +- server/src/services/apps/repo.rs | 2 +- server/src/services/auth/oauth/models.rs | 6 +- server/src/services/auth/oauth/service.rs | 4 +- .../src/services/auth/permissions/context.rs | 11 +- .../auth/permissions/context_tests.rs | 39 ++++-- .../src/services/auth/permissions/models.rs | 10 +- .../services/auth/publishable_keys/models.rs | 12 +- .../src/services/auth/secret_keys/models.rs | 18 +-- .../src/services/auth/secret_keys/service.rs | 43 ++++--- server/src/services/auth/sessions/models.rs | 20 +-- server/src/services/auth/sessions/service.rs | 22 ++-- server/src/services/auth/tenants/models.rs | 6 +- server/src/services/auth/tenants/service.rs | 5 +- server/src/services/auth/usage/models.rs | 18 ++- server/src/services/auth/users/models.rs | 13 +- server/src/services/auth/users/service.rs | 29 +++-- server/src/services/billing/models.rs | 2 +- server/src/services/billing/quota.rs | 19 +-- server/src/services/billing/quota_tests.rs | 18 +-- .../billing/repos/resource_counts_adapter.rs | 21 ++-- server/src/services/billing/service.rs | 12 +- server/src/services/billing/service_tests.rs | 22 ++-- server/src/services/conversions/models.rs | 33 +++-- .../src/services/conversions/parsers_tests.rs | 7 +- server/src/services/conversions/repo.rs | 17 ++- server/src/services/conversions/service.rs | 41 ++++--- server/src/services/domains/models.rs | 8 +- server/src/services/domains/repo.rs | 4 +- server/src/services/domains/service.rs | 3 +- server/src/services/install_events/models.rs | 8 +- server/src/services/install_events/repo.rs | 12 +- server/src/services/links/models.rs | 68 +++-------- server/src/services/links/repo.rs | 10 +- server/src/services/links/service.rs | 114 +++++++++--------- server/src/services/links/service_tests.rs | 27 +++-- server/src/services/webhooks/dispatcher.rs | 11 +- server/src/services/webhooks/models.rs | 14 +-- server/src/services/webhooks/service.rs | 8 +- server/tests/api/affiliate_credentials.rs | 26 ++-- server/tests/api/affiliates.rs | 4 +- server/tests/api/webhooks.rs | 17 ++- server/tests/common/mocks/app_users.rs | 13 +- server/tests/common/mocks/apps.rs | 8 +- server/tests/common/mocks/domains.rs | 16 ++- server/tests/common/mocks/links.rs | 22 ++-- server/tests/common/mocks/sdk_keys.rs | 4 +- server/tests/common/mocks/secret_keys.rs | 18 +-- server/tests/common/mocks/tenants.rs | 12 +- server/tests/common/mocks/users.rs | 11 +- server/tests/common/mocks/webhooks.rs | 21 ++-- server/tests/common/mod.rs | 24 ++-- 80 files changed, 747 insertions(+), 741 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2daea9c..39739b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,9 @@ The API layer uses a **vertical slice architecture**: ## Multi-Tenancy -All link data is scoped by `tenant_id` (the API key's ObjectId). The auth middleware injects -a `TenantId` extension into the request on successful API key validation. Route handlers -extract it via `Extension`. +All link data is scoped by `tenant_id` (a prefixed `tnt_<24hex>` public id; see +`core::public_id::TenantId`). The auth middleware injects a `TenantId` extension into the +request on successful API key validation. Route handlers extract it via `Extension`. Public endpoints (landing page, attribution reporting) resolve the tenant from the link_id itself. diff --git a/CLAUDE.md b/CLAUDE.md index 0d6a2f4..bec5ba3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,7 @@ Post-install events (signups, purchases, deposits) flow through a **sources** ab - **Attribution lookup** — events carry `user_id`; `ConversionsService::ingest_parsed` resolves `user_id → Attribution → link_id` via `LinksRepository::find_attribution_by_user` before inserting the event. Events with no matching attribution are logged and dropped. - **Hard line** — the API answers link-scoped questions only. User-scoped queries (cohorts, funnels, retention) are permanently out of scope. Metadata is stored verbatim but not indexed or queried in v1. - **Extensibility** — new integrations (RevenueCat, Stripe, etc.) are drop-in parser additions: implement `ConversionParser`, add a `SourceType` variant, add one line to `parser_for`. No schema migration required — `Source.signing_secret` and `Source.config` already exist for integration parsers to use. -- **Outbound webhook** — on successful ingestion, the service fires a `Conversion` webhook event with a stable `event_id` (the MongoDB ObjectId of the stored event) for customer-side dedup on retry. The webhook dispatcher's `find_active_for_event` query is wrapped in a 60-second `cached` layer to kill the per-event DB query hot path. +- **Outbound webhook** — on successful ingestion, the service fires a `Conversion` webhook event with a stable `event_id` (prefixed `cev_<24hex>` — the stored event's public id) for customer-side dedup on retry. The webhook dispatcher's `find_active_for_event` query is wrapped in a 60-second `cached` layer to kill the per-event DB query hot path. ## Adding a New Domain diff --git a/server/src/api/affiliates/routes.rs b/server/src/api/affiliates/routes.rs index 95c3c8a..3426e75 100644 --- a/server/src/api/affiliates/routes.rs +++ b/server/src/api/affiliates/routes.rs @@ -1,7 +1,6 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::oid::ObjectId; use serde_json::json; use std::sync::Arc; @@ -162,7 +161,7 @@ pub async fn delete_affiliate( post, path = "/v1/affiliates/{affiliate_id}/credentials", tag = "Affiliates", - params(("affiliate_id" = String, Path, description = "Affiliate ObjectId")), + params(("affiliate_id" = String, Path, description = "Affiliate id")), responses( (status = 201, description = "Credential minted; api_key shown once", body = CreateAffiliateCredentialResponse), (status = 403, description = "Caller scope cannot mint credentials", body = crate::error::ErrorResponse), @@ -175,23 +174,24 @@ pub async fn create_affiliate_credential( State(state): State>, axum::Extension(ctx): axum::Extension, axum::Extension(auth_key): axum::Extension, - Path(affiliate_id): Path, + Path(affiliate_id): Path, ) -> Response { let Some(svc) = &state.affiliates_service else { return no_database(); }; - let Ok(oid) = ObjectId::parse_str(&affiliate_id) else { - return invalid_id(); - }; match svc - .mint_credential(&ctx, AffiliateId::from_object_id(oid), auth_key.0) + .mint_credential( + &ctx, + affiliate_id, + crate::core::public_id::UserId::from_object_id(auth_key.0.to_object_id()), + ) .await { Ok(minted) => ( StatusCode::CREATED, Json(CreateAffiliateCredentialResponse { - id: minted.created_key.id.to_hex(), + id: minted.created_key.id.to_string(), affiliate_id: minted.affiliate_id, api_key: minted.created_key.key, key_prefix: minted.created_key.key_prefix, @@ -211,7 +211,7 @@ pub async fn create_affiliate_credential( get, path = "/v1/affiliates/{affiliate_id}/credentials", tag = "Affiliates", - params(("affiliate_id" = String, Path, description = "Affiliate ObjectId")), + params(("affiliate_id" = String, Path, description = "Affiliate id")), responses( (status = 200, description = "List of credentials (no raw secrets)", body = ListAffiliateCredentialsResponse), (status = 404, description = "Affiliate not found", body = crate::error::ErrorResponse), @@ -222,24 +222,18 @@ pub async fn create_affiliate_credential( pub async fn list_affiliate_credentials( State(state): State>, axum::Extension(ctx): axum::Extension, - Path(affiliate_id): Path, + Path(affiliate_id): Path, ) -> Response { let Some(svc) = &state.affiliates_service else { return no_database(); }; - let Ok(oid) = ObjectId::parse_str(&affiliate_id) else { - return invalid_id(); - }; - match svc - .list_credentials(&ctx, AffiliateId::from_object_id(oid)) - .await - { + match svc.list_credentials(&ctx, affiliate_id).await { Ok(keys) => { let creds: Vec = keys .into_iter() .map(|k| AffiliateCredentialDetail { - id: k.id.to_hex(), + id: k.id.to_string(), key_prefix: k.key_prefix, created_at: k.created_at.try_to_rfc3339_string().unwrap_or_default(), }) @@ -255,8 +249,8 @@ pub async fn list_affiliate_credentials( path = "/v1/affiliates/{affiliate_id}/credentials/{key_id}", tag = "Affiliates", params( - ("affiliate_id" = String, Path, description = "Affiliate ObjectId"), - ("key_id" = String, Path, description = "Credential ObjectId"), + ("affiliate_id" = String, Path, description = "Affiliate id"), + ("key_id" = String, Path, description = "Credential id"), ), responses( (status = 204, description = "Credential revoked"), @@ -268,22 +262,13 @@ pub async fn list_affiliate_credentials( pub async fn revoke_affiliate_credential( State(state): State>, axum::Extension(ctx): axum::Extension, - Path((affiliate_id, key_id)): Path<(String, String)>, + Path((affiliate_id, key_id)): Path<(AffiliateId, crate::core::public_id::SecretKeyId)>, ) -> Response { let Some(svc) = &state.affiliates_service else { return no_database(); }; - let Ok(aff_oid) = ObjectId::parse_str(&affiliate_id) else { - return invalid_id(); - }; - let Ok(key_oid) = ObjectId::parse_str(&key_id) else { - return invalid_id(); - }; - match svc - .revoke_credential(&ctx, AffiliateId::from_object_id(aff_oid), key_oid) - .await - { + match svc.revoke_credential(&ctx, affiliate_id, key_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => affiliate_error_to_response(e), } @@ -337,11 +322,3 @@ fn no_database() -> Response { ) .into_response() } - -fn invalid_id() -> Response { - ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid ID", "code": "invalid_id" })), - ) - .into_response() -} diff --git a/server/src/api/apps/routes.rs b/server/src/api/apps/routes.rs index 995c087..4feab05 100644 --- a/server/src/api/apps/routes.rs +++ b/server/src/api/apps/routes.rs @@ -1,7 +1,6 @@ use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::oid::ObjectId; use serde_json::json; use std::sync::Arc; @@ -90,8 +89,8 @@ pub async fn create_app( } let app = crate::services::apps::models::App { - id: ObjectId::new(), - tenant_id: tenant.to_object_id(), + id: crate::core::public_id::AppId::new(), + tenant_id: tenant, platform: platform.clone(), bundle_id: req.bundle_id, team_id: req.team_id, @@ -180,7 +179,7 @@ pub async fn list_apps( pub async fn delete_app( State(state): State>, axum::Extension(tenant): axum::Extension, - Path(app_id): Path, + Path(app_id): Path, ) -> Response { let Some(repo) = &state.apps_repo else { return ( @@ -190,15 +189,10 @@ pub async fn delete_app( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&app_id) else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid app_id", "code": "invalid_id" })), - ) - .into_response(); - }; - - match repo.delete_app(&tenant.to_object_id(), &oid).await { + match repo + .delete_app(&tenant.to_object_id(), &app_id.to_object_id()) + .await + { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, @@ -246,7 +240,7 @@ pub async fn serve_aasa(State(state): State>, headers: HeaderMap) }; let Some(ios_app) = repo - .find_by_tenant_platform(&tenant_id, "ios") + .find_by_tenant_platform(tenant_id.as_object_id(), "ios") .await .ok() .flatten() @@ -315,7 +309,7 @@ pub async fn serve_assetlinks(State(state): State>, headers: Heade }; let Some(android_app) = repo - .find_by_tenant_platform(&tenant_id, "android") + .find_by_tenant_platform(tenant_id.as_object_id(), "android") .await .ok() .flatten() @@ -357,7 +351,7 @@ pub async fn serve_assetlinks(State(state): State>, headers: Heade // ── Helpers ── /// Resolve tenant from X-Rift-Host or Host header for custom domain routing. -async fn resolve_tenant_from_host(state: &Arc, headers: &HeaderMap) -> Option { +async fn resolve_tenant_from_host(state: &Arc, headers: &HeaderMap) -> Option { let host = headers .get("x-rift-host") .or_else(|| headers.get("host")) @@ -380,7 +374,7 @@ async fn resolve_tenant_from_host(state: &Arc, headers: &HeaderMap) -> fn to_detail(app: &crate::services::apps::models::App) -> AppDetail { AppDetail { - id: app.id.to_hex(), + id: app.id, platform: app.platform.clone(), bundle_id: app.bundle_id.clone(), team_id: app.team_id.clone(), diff --git a/server/src/api/auth/middleware.rs b/server/src/api/auth/middleware.rs index c397a77..fb08279 100644 --- a/server/src/api/auth/middleware.rs +++ b/server/src/api/auth/middleware.rs @@ -3,14 +3,13 @@ use axum::http::StatusCode; use axum::middleware::Next; use axum::response::{IntoResponse, Json, Response}; use axum_extra::headers::{Cookie, HeaderMapExt}; -use mongodb::bson::oid::ObjectId; use serde_json::json; use std::net::SocketAddr; use std::sync::Arc; use x402_axum::paygate::PaygateProtocol; use x402_types::proto::v1; -use super::models::{AuthKeyId, SdkDomain, SessionId, TenantId, UserId}; +use super::models::{AuthKeyId, SdkDomain}; use crate::app::AppState; use crate::services::auth::keys; use crate::services::auth::permissions::AuthContext; @@ -64,11 +63,10 @@ pub async fn auth_gate( } // Inject tenant identity, key identity, and scope for downstream handlers. - req.extensions_mut() - .insert(TenantId::from_object_id(tenant_id)); + req.extensions_mut().insert(tenant_id); req.extensions_mut().insert(AuthKeyId(key_id)); req.extensions_mut().insert(AuthContext::for_secret_key( - TenantId::from_object_id(tenant_id), + tenant_id, key_id, scope.as_ref(), )); @@ -215,14 +213,12 @@ pub async fn session_auth_gate( match svc.lookup(&raw_token).await { Ok(Some(resolved)) => { - req.extensions_mut() - .insert(TenantId::from_object_id(resolved.tenant_id)); - req.extensions_mut() - .insert(UserId::from_object_id(resolved.user_id)); - req.extensions_mut().insert(SessionId(resolved.session_id)); + req.extensions_mut().insert(resolved.tenant_id); + req.extensions_mut().insert(resolved.user_id); + req.extensions_mut().insert(resolved.session_id); req.extensions_mut().insert(AuthContext::for_session( - TenantId::from_object_id(resolved.tenant_id), - UserId::from_object_id(resolved.user_id), + resolved.tenant_id, + resolved.user_id, resolved.session_id, )); @@ -274,14 +270,12 @@ pub async fn session_or_key_auth_gate( { match svc.lookup(&raw_token).await { Ok(Some(resolved)) => { - req.extensions_mut() - .insert(TenantId::from_object_id(resolved.tenant_id)); - req.extensions_mut() - .insert(UserId::from_object_id(resolved.user_id)); - req.extensions_mut().insert(SessionId(resolved.session_id)); + req.extensions_mut().insert(resolved.tenant_id); + req.extensions_mut().insert(resolved.user_id); + req.extensions_mut().insert(resolved.session_id); req.extensions_mut().insert(AuthContext::for_session( - TenantId::from_object_id(resolved.tenant_id), - UserId::from_object_id(resolved.user_id), + resolved.tenant_id, + resolved.user_id, resolved.session_id, )); @@ -330,11 +324,10 @@ pub async fn session_or_key_auth_gate( } } - req.extensions_mut() - .insert(TenantId::from_object_id(tenant_id)); + req.extensions_mut().insert(tenant_id); req.extensions_mut().insert(AuthKeyId(key_id)); req.extensions_mut().insert(AuthContext::for_secret_key( - TenantId::from_object_id(tenant_id), + tenant_id, key_id, scope.as_ref(), )); @@ -450,8 +443,7 @@ pub async fn sdk_auth_gate( .into_response(); } - req.extensions_mut() - .insert(TenantId::from_object_id(doc.tenant_id)); + req.extensions_mut().insert(doc.tenant_id); req.extensions_mut().insert(SdkDomain(doc.domain)); next.run(req).await @@ -502,7 +494,14 @@ fn extract_sdk_query_key(req: &Request) -> Option { async fn validate_api_key( secret_keys_repo: Option<&dyn SecretKeysRepository>, raw_key: &str, -) -> Result<(ObjectId, ObjectId, Option), Response> { +) -> Result< + ( + crate::core::public_id::TenantId, + crate::core::public_id::SecretKeyId, + Option, + ), + Response, +> { let hash = keys::hash_key(raw_key); let sk_repo = secret_keys_repo.ok_or_else(|| { diff --git a/server/src/api/auth/models.rs b/server/src/api/auth/models.rs index 7f7e6f7..1aff3a2 100644 --- a/server/src/api/auth/models.rs +++ b/server/src/api/auth/models.rs @@ -1,28 +1,20 @@ //! Axum extension types injected by `api/auth/middleware.rs` into request //! extensions, then extracted by route handlers via `Extension<...>`. //! -//! `TenantId` and `UserId` are re-exports of the typed identifiers from -//! `core::public_id` — there's no separate axum-extension newtype. The -//! middleware constructs the typed value once at the auth boundary and the -//! same type flows all the way through services and repos. +//! `TenantId`, `UserId`, and `SessionId` (alias for `AuthSessionId`) are +//! re-exports of the typed identifiers from `core::public_id`. The middleware +//! constructs the typed value once at the auth boundary and the same type +//! flows all the way through services and repos. //! -//! `AuthKeyId`, `SessionId`, `SdkDomain` are still local newtypes — they -//! aren't yet migrated to typed `Id

` aliases. Doing so is part of the -//! secret_keys / sessions migrations. +//! `AuthKeyId` and `SdkDomain` are still local newtypes — they aren't yet +//! migrated to typed `Id

` aliases (secret_keys migration pending). -use mongodb::bson::oid::ObjectId; +pub use crate::core::public_id::{AuthSessionId as SessionId, SecretKeyId, TenantId, UserId}; -pub use crate::core::public_id::{TenantId, UserId}; - -/// The ObjectId of the secret key used for authentication. +/// Identifier of the secret key used for authentication. /// Handlers extract this via `Extension`. #[derive(Debug, Clone)] -pub struct AuthKeyId(pub ObjectId); - -/// The active session's ObjectId — used by `POST /v1/auth/signout` to revoke -/// the exact session the caller arrived on. -#[derive(Debug, Clone)] -pub struct SessionId(pub ObjectId); +pub struct AuthKeyId(pub SecretKeyId); /// Domain associated with an SDK key, injected by `sdk_auth_gate`. #[derive(Debug, Clone)] diff --git a/server/src/api/auth/publishable_keys/routes.rs b/server/src/api/auth/publishable_keys/routes.rs index 70e53c0..82335ae 100644 --- a/server/src/api/auth/publishable_keys/routes.rs +++ b/server/src/api/auth/publishable_keys/routes.rs @@ -1,7 +1,6 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::oid::ObjectId; use mongodb::bson::DateTime; use serde_json::json; use std::sync::Arc; @@ -66,7 +65,7 @@ pub async fn create_sdk_key( } }; - if domain.tenant_id != tenant.to_object_id() { + if domain.tenant_id.to_object_id() != tenant.to_object_id() { return ( StatusCode::BAD_REQUEST, Json(json!({ "error": "Domain not owned by this tenant", "code": "domain_not_owned" })), @@ -85,8 +84,8 @@ pub async fn create_sdk_key( let (full_key, hash, prefix) = keys::generate_sdk_key(); let now = DateTime::now(); let doc = SdkKeyDoc { - id: ObjectId::new(), - tenant_id: tenant.to_object_id(), + id: crate::core::public_id::PublishableKeyId::new(), + tenant_id: tenant, key_hash: hash, key_prefix: prefix, domain: req.domain.clone(), @@ -106,7 +105,7 @@ pub async fn create_sdk_key( ( StatusCode::CREATED, Json(json!(CreateSdkKeyResponse { - id: doc.id.to_hex(), + id: doc.id.to_string(), key: full_key, domain: req.domain, created_at: now.try_to_rfc3339_string().unwrap_or_default(), @@ -144,7 +143,7 @@ pub async fn list_sdk_keys( let keys: Vec = docs .iter() .map(|d| SdkKeyDetail { - id: d.id.to_hex(), + id: d.id.to_string(), key_prefix: d.key_prefix.clone(), domain: d.domain.clone(), created_at: d.created_at.try_to_rfc3339_string().unwrap_or_default(), @@ -180,7 +179,7 @@ pub async fn list_sdk_keys( pub async fn revoke_sdk_key( State(state): State>, axum::Extension(tenant): axum::Extension, - Path(key_id): Path, + Path(key_id): Path, ) -> Response { let Some(sdk_keys_repo) = &state.sdk_keys_repo else { return ( @@ -190,15 +189,10 @@ pub async fn revoke_sdk_key( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&key_id) else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid key ID", "code": "bad_request" })), - ) - .into_response(); - }; - - match sdk_keys_repo.revoke(&tenant.to_object_id(), &oid).await { + match sdk_keys_repo + .revoke(&tenant.to_object_id(), &key_id.to_object_id()) + .await + { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, diff --git a/server/src/api/auth/secret_keys/models.rs b/server/src/api/auth/secret_keys/models.rs index d981199..2f5a1a5 100644 --- a/server/src/api/auth/secret_keys/models.rs +++ b/server/src/api/auth/secret_keys/models.rs @@ -40,7 +40,7 @@ pub struct ConfirmCreateKeyRequest { #[derive(Debug, Serialize, ToSchema)] pub struct CreateKeyResponse { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + #[schema(example = "skid_665a1b2c3d4e5f6a7b8c9d0e")] pub id: String, /// The full secret key. Shown only once at creation time. #[schema(example = "rl_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2")] @@ -53,11 +53,11 @@ pub struct CreateKeyResponse { #[derive(Debug, Serialize, ToSchema)] pub struct SecretKeyDetail { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + #[schema(example = "skid_665a1b2c3d4e5f6a7b8c9d0e")] pub id: String, #[schema(example = "rl_live_a1b2c3d4...")] pub key_prefix: String, - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0f")] + #[schema(example = "usr_665a1b2c3d4e5f6a7b8c9d0f")] pub created_by: String, #[schema(example = "2025-06-15T10:30:00Z")] pub created_at: String, diff --git a/server/src/api/auth/secret_keys/routes.rs b/server/src/api/auth/secret_keys/routes.rs index 29bb28c..29a53a8 100644 --- a/server/src/api/auth/secret_keys/routes.rs +++ b/server/src/api/auth/secret_keys/routes.rs @@ -1,7 +1,6 @@ use axum::extract::{Form, Path, Query, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::oid::ObjectId; use serde_json::json; use std::sync::Arc; @@ -184,7 +183,7 @@ pub async fn confirm_create_key( ( StatusCode::CREATED, Json(json!(CreateKeyResponse { - id: created.id.to_hex(), + id: created.id.to_string(), key: created.key, key_prefix: created.key_prefix, created_at: created @@ -226,9 +225,9 @@ pub async fn list_secret_keys( let details: Vec = keys .iter() .map(|k| SecretKeyDetail { - id: k.id.to_hex(), + id: k.id.to_string(), key_prefix: k.key_prefix.clone(), - created_by: k.created_by.to_hex(), + created_by: k.created_by.to_string(), created_at: k.created_at.try_to_rfc3339_string().unwrap_or_default(), }) .collect(); @@ -254,7 +253,7 @@ pub async fn list_secret_keys( pub async fn delete_secret_key( State(state): State>, axum::Extension(ctx): axum::Extension, - Path(key_id): Path, + Path(key_id): Path, ) -> Response { let Some(svc) = &state.secret_keys_service else { return ( @@ -264,18 +263,10 @@ pub async fn delete_secret_key( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&key_id) else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid key ID", "code": "bad_request" })), - ) - .into_response(); - }; - // Self-delete guard derives from `ctx.principal` inside the service — // `Principal::SecretKey` matches request key_id means self-delete; sessions // can't self-delete because their principal is `User`. - match svc.delete(&ctx, oid).await { + match svc.delete(&ctx, key_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => sk_error_response(&e), } diff --git a/server/src/api/auth/sessions/models.rs b/server/src/api/auth/sessions/models.rs index 6cb5de2..d38ae38 100644 --- a/server/src/api/auth/sessions/models.rs +++ b/server/src/api/auth/sessions/models.rs @@ -50,7 +50,7 @@ pub struct MeResponse { #[derive(Debug, Serialize, ToSchema)] pub struct UserSummary { - pub id: String, + pub id: crate::core::public_id::UserId, pub email: String, pub verified: bool, pub is_owner: bool, @@ -58,7 +58,7 @@ pub struct UserSummary { #[derive(Debug, Serialize, ToSchema)] pub struct TenantSummary { - pub id: String, + pub id: crate::core::public_id::TenantId, } /// `POST /v1/auth/secret-keys/issue` request body. diff --git a/server/src/api/auth/sessions/routes.rs b/server/src/api/auth/sessions/routes.rs index b4ddb78..f9ee5a5 100644 --- a/server/src/api/auth/sessions/routes.rs +++ b/server/src/api/auth/sessions/routes.rs @@ -272,7 +272,7 @@ pub async fn me( } }; - let Some(user_detail) = users.into_iter().find(|u| u.id == user.to_object_id()) else { + let Some(user_detail) = users.into_iter().find(|u| u.id == user) else { // Session points at a user that no longer exists. Treat as a stale // session — caller should re-sign-in. return ( @@ -284,14 +284,12 @@ pub async fn me( Json(MeResponse { user: UserSummary { - id: user_detail.id.to_hex(), + id: user_detail.id, email: user_detail.email, verified: user_detail.verified, is_owner: user_detail.is_owner, }, - tenant: TenantSummary { - id: ctx.tenant_id.as_hex(), - }, + tenant: TenantSummary { id: ctx.tenant_id }, }) .into_response() } @@ -321,7 +319,7 @@ pub async fn sign_out( .into_response(); }; - if let Err(e) = svc.revoke(&session.0).await { + if let Err(e) = svc.revoke(&session).await { tracing::error!(error = %e, "signout_failed"); } @@ -374,7 +372,7 @@ pub async fn issue_secret_key( Ok(created) => ( StatusCode::CREATED, Json(CreateKeyResponse { - id: created.id.to_hex(), + id: created.id.to_string(), key: created.key, key_prefix: created.key_prefix, created_at: created diff --git a/server/src/api/auth/users/models.rs b/server/src/api/auth/users/models.rs index 7e21355..f72b90f 100644 --- a/server/src/api/auth/users/models.rs +++ b/server/src/api/auth/users/models.rs @@ -12,8 +12,7 @@ pub struct InviteUserRequest { #[derive(Debug, Serialize, ToSchema)] pub struct InviteUserResponse { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: crate::core::public_id::UserId, #[schema(example = "alice@example.com")] pub email: String, #[schema(example = "verification_sent")] @@ -22,8 +21,7 @@ pub struct InviteUserResponse { #[derive(Debug, Serialize, ToSchema)] pub struct UserDetail { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: crate::core::public_id::UserId, #[schema(example = "alice@example.com")] pub email: String, #[schema(example = true)] diff --git a/server/src/api/auth/users/routes.rs b/server/src/api/auth/users/routes.rs index aa9cb16..002c9b9 100644 --- a/server/src/api/auth/users/routes.rs +++ b/server/src/api/auth/users/routes.rs @@ -1,7 +1,6 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::oid::ObjectId; use serde_json::json; use std::sync::Arc; @@ -55,7 +54,7 @@ pub async fn invite_user( ( StatusCode::CREATED, Json(json!(InviteUserResponse { - id: result.user_id.to_hex(), + id: result.user_id, email: result.email, status: "verification_sent".to_string(), })), @@ -93,7 +92,7 @@ pub async fn list_users( let details: Vec = users .iter() .map(|u| UserDetail { - id: u.id.to_hex(), + id: u.id, email: u.email.clone(), verified: u.verified, is_owner: u.is_owner, @@ -122,7 +121,7 @@ pub async fn list_users( pub async fn delete_user( State(state): State>, axum::Extension(ctx): axum::Extension, - Path(user_id): Path, + Path(user_id): Path, ) -> Response { let Some(svc) = &state.users_service else { return ( @@ -132,15 +131,7 @@ pub async fn delete_user( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&user_id) else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid user ID", "code": "bad_request" })), - ) - .into_response(); - }; - - match svc.delete(&ctx, oid).await { + match svc.delete(&ctx, user_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(e), } diff --git a/server/src/api/billing/stripe_webhook.rs b/server/src/api/billing/stripe_webhook.rs index 11784c3..b8e8ac1 100644 --- a/server/src/api/billing/stripe_webhook.rs +++ b/server/src/api/billing/stripe_webhook.rs @@ -16,7 +16,7 @@ use axum::body::Bytes; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::{self, oid::ObjectId}; +use mongodb::bson; use serde::Deserialize; use serde_json::{json, Value}; use std::sync::Arc; @@ -265,7 +265,7 @@ async fn handle_subscription_upsert( }; tenants - .apply_subscription_update(&tenant_id, update) + .apply_subscription_update(&tenant_id.to_object_id(), update) .await?; Ok(()) @@ -283,7 +283,9 @@ async fn handle_subscription_deleted( tracing::warn!(customer = %sub.customer, "stripe_webhook_deleted_no_tenant"); return Ok(()); }; - tenants.clear_subscription(&tenant_id).await?; + tenants + .clear_subscription(&tenant_id.to_object_id()) + .await?; Ok(()) } @@ -322,7 +324,7 @@ async fn handle_invoice_status( ..SubscriptionUpdate::default() }; tenants - .apply_subscription_update(&tenant_id, update) + .apply_subscription_update(&tenant_id.to_object_id(), update) .await?; Ok(()) } @@ -335,7 +337,7 @@ async fn try_resolve_tenant( tenants: &dyn crate::services::auth::tenants::repo::TenantsRepository, metadata: &serde_json::Map, customer_id: &str, -) -> Result, String> { +) -> Result, String> { if let Some(id) = tenant_id_from_metadata(metadata) { return Ok(Some(id)); } @@ -345,10 +347,12 @@ async fn try_resolve_tenant( // ── Helpers ── -fn tenant_id_from_metadata(meta: &serde_json::Map) -> Option { +fn tenant_id_from_metadata( + meta: &serde_json::Map, +) -> Option { meta.get("tenant_id") .and_then(|v| v.as_str()) - .and_then(|s| ObjectId::parse_str(s).ok()) + .and_then(|s| crate::core::public_id::TenantId::parse(s).ok()) } fn price_id_to_tier(state: &AppState, price_id: &str) -> Option { diff --git a/server/src/api/conversions/routes.rs b/server/src/api/conversions/routes.rs index 996ab17..236f5dd 100644 --- a/server/src/api/conversions/routes.rs +++ b/server/src/api/conversions/routes.rs @@ -2,7 +2,6 @@ use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::oid::ObjectId; use serde_json::json; use std::sync::Arc; @@ -57,7 +56,7 @@ pub async fn create_source( { Ok(source) => { let resp = CreateSourceResponse { - id: source.id.to_hex(), + id: source.id, name: source.name.clone(), source_type: source.source_type.clone(), webhook_url: webhook_url_for(&state, &source.url_token), @@ -166,7 +165,7 @@ pub async fn list_sources( pub async fn get_source( State(state): State>, axum::Extension(tenant): axum::Extension, - Path(id): Path, + Path(id): Path, ) -> Response { let Some(repo) = &state.conversions_repo else { return ( @@ -176,15 +175,10 @@ pub async fn get_source( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&id) else { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Source not found", "code": "not_found" })), - ) - .into_response(); - }; - - match repo.find_source_by_id(&tenant.to_object_id(), &oid).await { + match repo + .find_source_by_id(&tenant.to_object_id(), &id.to_object_id()) + .await + { Ok(Some(source)) => Json(to_detail(&state, &source)).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, @@ -219,7 +213,7 @@ pub async fn get_source( pub async fn delete_source( State(state): State>, axum::Extension(tenant): axum::Extension, - Path(id): Path, + Path(id): Path, ) -> Response { let Some(repo) = &state.conversions_repo else { return ( @@ -229,15 +223,10 @@ pub async fn delete_source( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&id) else { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Source not found", "code": "not_found" })), - ) - .into_response(); - }; - - match repo.delete_source(&tenant.to_object_id(), &oid).await { + match repo + .delete_source(&tenant.to_object_id(), &id.to_object_id()) + .await + { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, @@ -366,9 +355,7 @@ pub async fn sdk_track_conversion( occurred_at: None, }]; - let result = service - .ingest_sdk_event(tenant.to_object_id(), parsed) - .await; + let result = service.ingest_sdk_event(tenant, parsed).await; Json(json!({ "accepted": result.accepted, @@ -391,7 +378,7 @@ fn webhook_url_for(state: &AppState, url_token: &str) -> String { fn to_detail(state: &AppState, source: &Source) -> SourceDetail { SourceDetail { - id: source.id.to_hex(), + id: source.id, name: source.name.clone(), source_type: source.source_type.clone(), webhook_url: webhook_url_for(state, &source.url_token), diff --git a/server/src/api/domains/routes.rs b/server/src/api/domains/routes.rs index b1adc5e..f9032c9 100644 --- a/server/src/api/domains/routes.rs +++ b/server/src/api/domains/routes.rs @@ -300,7 +300,7 @@ pub async fn verify_domain( .into_response(); }; - if existing.tenant_id != tenant.to_object_id() { + if existing.tenant_id.to_object_id() != tenant.to_object_id() { return ( StatusCode::NOT_FOUND, Json(json!({ "error": "Domain not found", "code": "not_found" })), diff --git a/server/src/api/lifecycle/routes.rs b/server/src/api/lifecycle/routes.rs index 4d28be5..ec2e964 100644 --- a/server/src/api/lifecycle/routes.rs +++ b/server/src/api/lifecycle/routes.rs @@ -109,7 +109,7 @@ pub async fn lifecycle_click( if let Some(dispatcher) = &state.webhook_dispatcher { dispatcher.dispatch_click(ClickEventPayload { - tenant_id: link.tenant_id.to_hex(), + tenant_id: link.tenant_id.to_string(), link_id: req.link_id.clone(), user_agent, referer, @@ -228,7 +228,7 @@ pub async fn lifecycle_attribute( .as_ref() .and_then(|d| serde_json::to_value(d).ok()); dispatcher.dispatch_attribute(AttributeEventPayload { - tenant_id: link.tenant_id.to_hex(), + tenant_id: link.tenant_id.to_string(), link_id: req.link_id.clone(), install_id: req.install_id.clone(), app_version: req.app_version.clone(), @@ -283,7 +283,7 @@ pub async fn lifecycle_identify( }; match svc - .identify_install(&tenant.to_object_id(), &req.install_id, &req.user_id) + .identify_install(&tenant, &req.install_id, &req.user_id) .await { Ok(IdentifyOutcome::Created(credited)) | Ok(IdentifyOutcome::InstallAdded(credited)) => { @@ -294,13 +294,7 @@ pub async fn lifecycle_identify( user_id = %req.user_id, "identify bound; firing webhook" ); - fire_identify_event( - &state, - &tenant.to_object_id(), - &req.install_id, - &req.user_id, - credited, - ); + fire_identify_event(&state, &tenant, &req.install_id, &req.user_id, credited); Json(json!({ "success": true })).into_response() } Ok(IdentifyOutcome::AlreadyPresent) => { @@ -346,7 +340,7 @@ pub async fn lifecycle_identify( /// acquisition source without querying Rift back. fn fire_identify_event( state: &Arc, - tenant_id: &mongodb::bson::oid::ObjectId, + tenant_id: &crate::core::public_id::TenantId, install_id: &str, user_id: &str, credited: CreditedLinks, @@ -355,7 +349,7 @@ fn fire_identify_event( return; }; dispatcher.dispatch_identify(IdentifyEventPayload { - tenant_id: tenant_id.to_hex(), + tenant_id: tenant_id.to_string(), user_id: user_id.to_string(), install_id: install_id.to_string(), first_touch_link_id: credited.first_touch_link_id, diff --git a/server/src/api/links/routes.rs b/server/src/api/links/routes.rs index cb052ce..245823f 100644 --- a/server/src/api/links/routes.rs +++ b/server/src/api/links/routes.rs @@ -2,7 +2,6 @@ use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Json, Redirect, Response}; use chrono::Utc; -use mongodb::bson::oid::ObjectId; use mongodb::bson::DateTime; use serde_json::json; use std::sync::Arc; @@ -446,7 +445,7 @@ pub async fn resolve_link_custom( } let Some(link) = repo - .find_link_by_tenant_and_id(&domain.tenant_id, &link_id) + .find_link_by_tenant_and_id(domain.tenant_id.as_object_id(), &link_id) .await .ok() .flatten() @@ -568,7 +567,7 @@ async fn do_resolve( if let Some(dispatcher) = &state.webhook_dispatcher { dispatcher.dispatch_click(ClickEventPayload { - tenant_id: link.tenant_id.to_hex(), + tenant_id: link.tenant_id.to_string(), link_id: link_id.to_string(), user_agent, referer, @@ -666,12 +665,12 @@ async fn do_resolve( Platform::Other => "android", }; let app = apps_repo - .find_by_tenant_platform(&link.tenant_id, preferred) + .find_by_tenant_platform(link.tenant_id.as_object_id(), preferred) .await .ok() .flatten() .or(apps_repo - .find_by_tenant_platform(&link.tenant_id, fallback) + .find_by_tenant_platform(link.tenant_id.as_object_id(), fallback) .await .ok() .flatten()); @@ -687,7 +686,7 @@ async fn do_resolve( // Look up alternate domain for the "Open in App" button. let alternate_domain = if let Some(domains_repo) = &state.domains_repo { domains_repo - .find_alternate_by_tenant(&link.tenant_id) + .find_alternate_by_tenant(link.tenant_id.as_object_id()) .await .ok() .flatten() @@ -879,13 +878,13 @@ fn compute_link_status(link: &Link) -> &'static str { async fn lookup_tenant_domain( domains_repo: Option<&dyn DomainsRepository>, - tenant_id: &ObjectId, + tenant_id: &crate::core::public_id::TenantId, ) -> (Option, bool) { let Some(repo) = domains_repo else { return (None, false); }; let domains = repo - .list_by_tenant(tenant_id) + .list_by_tenant(tenant_id.as_object_id()) .await .ok() .unwrap_or_default(); diff --git a/server/src/api/webhooks/routes.rs b/server/src/api/webhooks/routes.rs index 7afc48b..a045a70 100644 --- a/server/src/api/webhooks/routes.rs +++ b/server/src/api/webhooks/routes.rs @@ -1,7 +1,7 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use serde_json::json; use std::sync::Arc; @@ -53,7 +53,7 @@ pub async fn create_webhook( let secret = generate_secret(); let now = DateTime::now(); - let id = ObjectId::new(); + let id = crate::core::public_id::WebhookId::new(); match svc .create_webhook( @@ -69,7 +69,7 @@ pub async fn create_webhook( Ok(_) => ( StatusCode::CREATED, Json(CreateWebhookResponse { - id: id.to_hex(), + id, url: req.url, events: req.events, secret, @@ -117,7 +117,7 @@ pub async fn list_webhooks( let details: Vec = webhooks .into_iter() .map(|w| WebhookDetail { - id: w.id.to_hex(), + id: w.id, url: w.url, events: w.events, active: w.active, @@ -152,7 +152,7 @@ pub async fn list_webhooks( pub async fn delete_webhook( State(state): State>, axum::Extension(tenant): axum::Extension, - Path(webhook_id): Path, + Path(webhook_id): Path, ) -> Response { let Some(repo) = &state.webhooks_repo else { return ( @@ -162,15 +162,10 @@ pub async fn delete_webhook( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&webhook_id) else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid webhook ID", "code": "invalid_id" })), - ) - .into_response(); - }; - - match repo.delete_webhook(&tenant.to_object_id(), &oid).await { + match repo + .delete_webhook(&tenant.to_object_id(), &webhook_id.to_object_id()) + .await + { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, @@ -204,7 +199,7 @@ pub async fn delete_webhook( pub async fn patch_webhook( State(state): State>, axum::Extension(tenant): axum::Extension, - Path(webhook_id): Path, + Path(webhook_id): Path, Json(req): Json, ) -> Response { let Some(repo) = &state.webhooks_repo else { @@ -215,13 +210,7 @@ pub async fn patch_webhook( .into_response(); }; - let Ok(oid) = ObjectId::parse_str(&webhook_id) else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid webhook ID", "code": "invalid_id" })), - ) - .into_response(); - }; + let oid = webhook_id.to_object_id(); if req.active.is_none() && req.events.is_none() && req.url.is_none() { return ( @@ -271,9 +260,9 @@ pub async fn patch_webhook( .list_by_tenant(&tenant.to_object_id()) .await .unwrap_or_default(); - match webhooks.iter().find(|w| w.id == oid) { + match webhooks.iter().find(|w| w.id.to_object_id() == oid) { Some(w) => Json(json!({ - "id": w.id.to_hex(), + "id": w.id, "url": w.url, "events": w.events, "active": w.active, diff --git a/server/src/architecture_tests.rs b/server/src/architecture_tests.rs index e0f29ac..d50c040 100644 --- a/server/src/architecture_tests.rs +++ b/server/src/architecture_tests.rs @@ -307,52 +307,7 @@ const AUTH_MIGRATION_BACKLOG: &[&str] = &[]; /// done. See issue #156. /// /// New files inherit enforcement — do not add entries here. -const OBJECT_ID_BACKLOG: &[&str] = &[ - "src/api/affiliates/routes.rs", - "src/api/apps/routes.rs", - "src/api/auth/middleware.rs", - "src/api/auth/models.rs", - "src/api/auth/publishable_keys/routes.rs", - "src/api/auth/secret_keys/routes.rs", - "src/api/auth/users/routes.rs", - "src/api/billing/stripe_webhook.rs", - "src/api/conversions/routes.rs", - "src/api/lifecycle/routes.rs", - "src/api/links/routes.rs", - "src/api/webhooks/routes.rs", - "src/services/affiliates/service.rs", - "src/services/analytics/service.rs", - "src/services/app_users/models.rs", - "src/services/apps/models.rs", - "src/services/auth/oauth/models.rs", - "src/services/auth/oauth/service.rs", - "src/services/auth/permissions/context.rs", - "src/services/auth/permissions/models.rs", - "src/services/auth/publishable_keys/models.rs", - "src/services/auth/secret_keys/models.rs", - "src/services/auth/secret_keys/service.rs", - "src/services/auth/sessions/models.rs", - "src/services/auth/sessions/service.rs", - "src/services/auth/tenants/models.rs", - "src/services/auth/tenants/service.rs", - "src/services/auth/usage/models.rs", - "src/services/auth/users/models.rs", - "src/services/auth/users/service.rs", - "src/services/billing/models.rs", - "src/services/billing/quota.rs", - "src/services/billing/repos/event_counters.rs", - "src/services/billing/repos/resource_counts_adapter.rs", - "src/services/billing/service.rs", - "src/services/conversions/models.rs", - "src/services/conversions/service.rs", - "src/services/domains/models.rs", - "src/services/install_events/models.rs", - "src/services/links/models.rs", - "src/services/links/service.rs", - "src/services/webhooks/dispatcher.rs", - "src/services/webhooks/models.rs", - "src/services/webhooks/service.rs", -]; +const OBJECT_ID_BACKLOG: &[&str] = &[]; /// Whether `path` is on the cleanup backlog (suppress pub-types check only). fn is_cleanup_backlog(path: &std::path::Path) -> bool { diff --git a/server/src/core/public_id/mod.rs b/server/src/core/public_id/mod.rs index 71942ab..69bd89c 100644 --- a/server/src/core/public_id/mod.rs +++ b/server/src/core/public_id/mod.rs @@ -20,8 +20,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub mod models; pub use models::{ - AffiliateId, AppId, ConversionEventId, DomainId, ParseIdError, PublishableKeyId, SecretKeyId, - SourceId, TenantId, UserId, WebhookId, + AffiliateId, AppId, AppUserId, AuthSessionId, ConversionEventId, DomainId, InstallEventId, + LinkInternalId, OAuthSessionId, ParseIdError, PublishableKeyId, SecretKeyId, SourceId, + TenantId, UserId, WebhookId, }; /// Implemented by zero-sized marker types to declare a resource's prefix and schema name. @@ -280,6 +281,45 @@ impl IdPrefix for DomainIdMarker { const SCHEMA_NAME: &'static str = "DomainId"; } +crate::impl_container!(AppUserIdMarker); +pub struct AppUserIdMarker; +impl IdPrefix for AppUserIdMarker { + const PREFIX: &'static str = "appusr"; + const SCHEMA_NAME: &'static str = "AppUserId"; +} + +crate::impl_container!(InstallEventIdMarker); +pub struct InstallEventIdMarker; +impl IdPrefix for InstallEventIdMarker { + const PREFIX: &'static str = "iev"; + const SCHEMA_NAME: &'static str = "InstallEventId"; +} + +crate::impl_container!(LinkInternalIdMarker); +pub struct LinkInternalIdMarker; +impl IdPrefix for LinkInternalIdMarker { + // Distinct from the public `link_id` vanity slug (which stays as a String). + // This is the internal `_id: ObjectId` of stored Link documents. + const PREFIX: &'static str = "lnk"; + const SCHEMA_NAME: &'static str = "LinkInternalId"; +} + +crate::impl_container!(AuthSessionIdMarker); +pub struct AuthSessionIdMarker; +impl IdPrefix for AuthSessionIdMarker { + // User auth session id (browser login). + const PREFIX: &'static str = "sess"; + const SCHEMA_NAME: &'static str = "AuthSessionId"; +} + +crate::impl_container!(OAuthSessionIdMarker); +pub struct OAuthSessionIdMarker; +impl IdPrefix for OAuthSessionIdMarker { + // Pending OAuth flow session id (state during the round-trip with Google/etc.). + const PREFIX: &'static str = "osess"; + const SCHEMA_NAME: &'static str = "OAuthSessionId"; +} + crate::impl_container!(PublishableKeyIdMarker); pub struct PublishableKeyIdMarker; impl IdPrefix for PublishableKeyIdMarker { diff --git a/server/src/core/public_id/models.rs b/server/src/core/public_id/models.rs index bc37c3c..ee4c2a8 100644 --- a/server/src/core/public_id/models.rs +++ b/server/src/core/public_id/models.rs @@ -3,7 +3,8 @@ //! hosts every trait impl in this module. use super::{ - AffiliateIdMarker, AppIdMarker, ConversionEventIdMarker, DomainIdMarker, Id, + AffiliateIdMarker, AppIdMarker, AppUserIdMarker, AuthSessionIdMarker, ConversionEventIdMarker, + DomainIdMarker, Id, InstallEventIdMarker, LinkInternalIdMarker, OAuthSessionIdMarker, PublishableKeyIdMarker, SecretKeyIdMarker, SourceIdMarker, TenantIdMarker, UserIdMarker, WebhookIdMarker, }; @@ -23,8 +24,13 @@ pub enum ParseIdError { pub type AffiliateId = Id; pub type AppId = Id; +pub type AppUserId = Id; +pub type AuthSessionId = Id; pub type ConversionEventId = Id; pub type DomainId = Id; +pub type InstallEventId = Id; +pub type LinkInternalId = Id; +pub type OAuthSessionId = Id; pub type PublishableKeyId = Id; pub type SecretKeyId = Id; pub type SourceId = Id; diff --git a/server/src/mcp/models.rs b/server/src/mcp/models.rs index 67830ee..b49fce2 100644 --- a/server/src/mcp/models.rs +++ b/server/src/mcp/models.rs @@ -88,8 +88,7 @@ pub struct DeleteLinkOutput { /// Output for the `sources.create` MCP tool. #[derive(Debug, Serialize, JsonSchema)] pub struct CreateSourceOutput { - /// The newly-created source's ID (hex-encoded ObjectId). - pub id: String, + pub id: crate::core::public_id::SourceId, /// Human-readable name as supplied by the caller. pub name: String, /// Source type — currently always `custom`. @@ -101,8 +100,7 @@ pub struct CreateSourceOutput { /// One conversion source with its derived webhook URL. #[derive(Debug, Serialize, JsonSchema)] pub struct SourceSummary { - /// Source ID (hex-encoded ObjectId). - pub id: String, + pub id: crate::core::public_id::SourceId, /// Human-readable name. pub name: String, /// Source type. diff --git a/server/src/mcp/server.rs b/server/src/mcp/server.rs index 6c54686..c36bda7 100644 --- a/server/src/mcp/server.rs +++ b/server/src/mcp/server.rs @@ -85,7 +85,7 @@ impl RiftMcp { } Ok(AuthContext::for_secret_key( - crate::core::public_id::TenantId::from_object_id(key_doc.tenant_id), + key_doc.tenant_id, key_doc.id, Some(&KeyScope::Full), )) @@ -312,7 +312,7 @@ impl RiftMcp { let webhook_url = self.webhook_url_for(&source.url_token); Ok(Json(CreateSourceOutput { - id: source.id.to_hex(), + id: source.id, name: source.name, source_type: source.source_type, webhook_url, @@ -359,7 +359,7 @@ impl RiftMcp { .into_iter() .map(|s| SourceSummary { webhook_url: self.webhook_url_for(&s.url_token), - id: s.id.to_hex(), + id: s.id, name: s.name, source_type: s.source_type, created_at: s.created_at.try_to_rfc3339_string().unwrap_or_default(), diff --git a/server/src/services/affiliates/models.rs b/server/src/services/affiliates/models.rs index 78593a7..f743cbd 100644 --- a/server/src/services/affiliates/models.rs +++ b/server/src/services/affiliates/models.rs @@ -83,7 +83,7 @@ pub struct UpdateAffiliateRequest { #[derive(Debug, Serialize, ToSchema)] pub struct CreateAffiliateCredentialResponse { /// Credential id (the secret key id). - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + #[schema(example = "skid_665a1b2c3d4e5f6a7b8c9d0e")] pub id: String, /// Affiliate this credential is scoped to. pub affiliate_id: AffiliateId, @@ -99,7 +99,7 @@ pub struct CreateAffiliateCredentialResponse { #[derive(Debug, Serialize, ToSchema)] pub struct AffiliateCredentialDetail { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + #[schema(example = "skid_665a1b2c3d4e5f6a7b8c9d0e")] pub id: String, #[schema(example = "rl_live_4f2c3a8b9d...")] pub key_prefix: String, diff --git a/server/src/services/affiliates/service.rs b/server/src/services/affiliates/service.rs index afd6b6c..9be5a2f 100644 --- a/server/src/services/affiliates/service.rs +++ b/server/src/services/affiliates/service.rs @@ -1,4 +1,3 @@ -use mongodb::bson::oid::ObjectId; use mongodb::bson::DateTime; use rift_macros::requires; use std::sync::Arc; @@ -8,7 +7,7 @@ use super::models::{ MAX_CREDENTIALS_PER_AFFILIATE, }; use super::repo::AffiliatesRepository; -use crate::core::public_id::AffiliateId; +use crate::core::public_id::{AffiliateId, SecretKeyId, UserId}; use crate::services::auth::permissions::{AuthContext, Permission}; use crate::services::auth::secret_keys::repo::{KeyScope, SecretKeysRepository}; use crate::services::auth::secret_keys::service::mint_scoped; @@ -49,8 +48,7 @@ impl AffiliatesService { validate_partner_key(&partner_key)?; if let Some(q) = &self.quota { - q.check(ctx.tenant_id.as_object_id(), Resource::CreateAffiliate) - .await?; + q.check(&ctx.tenant_id, Resource::CreateAffiliate).await?; } // Pre-check uniqueness for a clean error before hitting the DB write. @@ -159,8 +157,7 @@ impl AffiliatesService { &self, ctx: &AuthContext, affiliate_id: AffiliateId, - // `created_by` is still ObjectId until users/secret_keys migrate. - created_by: ObjectId, + created_by: UserId, ) -> Result { // Affiliate must exist in this tenant. self.repo @@ -183,11 +180,9 @@ impl AffiliatesService { let created_key = mint_scoped( self.secret_keys_repo.as_ref(), - ctx.tenant_id.to_object_id(), + ctx.tenant_id, created_by, - KeyScope::Affiliate { - affiliate_id: aff_oid, - }, + KeyScope::Affiliate { affiliate_id }, ) .await .map_err(AffiliateError::Internal)?; @@ -230,8 +225,7 @@ impl AffiliatesService { &self, ctx: &AuthContext, affiliate_id: AffiliateId, - // `key_id` is still ObjectId — secret_keys hasn't migrated yet. - key_id: ObjectId, + key_id: SecretKeyId, ) -> Result<(), AffiliateError> { // Surface affiliate-not-found distinctly from credential-not-found. self.repo @@ -245,7 +239,7 @@ impl AffiliatesService { .delete_affiliate_credential( ctx.tenant_id.as_object_id(), &affiliate_id.to_object_id(), - &key_id, + &key_id.to_object_id(), ) .await .map_err(AffiliateError::Internal)?; diff --git a/server/src/services/analytics/service.rs b/server/src/services/analytics/service.rs index 5725113..eb9d9b3 100644 --- a/server/src/services/analytics/service.rs +++ b/server/src/services/analytics/service.rs @@ -54,12 +54,17 @@ impl AnalyticsService { return Err(AnalyticsError::InvalidDateRange); } - let tenant_id = ctx.tenant_id.as_object_id(); + let tenant_id = &ctx.tenant_id; // 2. Clicks — credit-independent. Direct count over click_events. let clicks = self .links_repo - .count_clicks_for_links(tenant_id, ¶ms.link_ids, params.from, params.to) + .count_clicks_for_links( + tenant_id.as_object_id(), + ¶ms.link_ids, + params.from, + params.to, + ) .await .map_err(AnalyticsError::Internal)?; @@ -70,7 +75,7 @@ impl AnalyticsService { let credited_installs = self .links_repo .distinct_install_ids_credited_to_links( - tenant_id, + tenant_id.as_object_id(), ¶ms.link_ids, params.from, params.to, @@ -131,7 +136,7 @@ impl AnalyticsService { let conversions: BTreeMap = match &self.conversions_repo { Some(cr) => cr .count_conversions_by_type_credited_to_links( - tenant_id, + tenant_id.as_object_id(), ¶ms.link_ids, params.from, params.to, @@ -175,11 +180,12 @@ impl AnalyticsService { async fn count_lifecycle( &self, - tenant_id: &mongodb::bson::oid::ObjectId, + tenant_id: &crate::core::public_id::TenantId, event_type: InstallEventType, install_ids: &[String], params: &FunnelParams, ) -> Result { + let tenant_id = &tenant_id.to_object_id(); self.install_events_repo .count_events_by_type_for_installs( tenant_id, diff --git a/server/src/services/app_users/models.rs b/server/src/services/app_users/models.rs index d432d83..97b4e7e 100644 --- a/server/src/services/app_users/models.rs +++ b/server/src/services/app_users/models.rs @@ -9,14 +9,16 @@ //! distinct concepts — Rift's customers are tenants, their team members are //! `users`, and the end-users of the customer's app are `app_users`. -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; +use crate::core::public_id::{AppUserId, TenantId}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppUserDoc { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, - pub tenant_id: ObjectId, + pub id: Option, + pub tenant_id: TenantId, /// Customer-supplied identifier for the end-user. Unique within tenant. pub user_id: String, /// Every install_id ever bound to this user. Accumulates over time as diff --git a/server/src/services/apps/models.rs b/server/src/services/apps/models.rs index ed2fe3f..18fba98 100644 --- a/server/src/services/apps/models.rs +++ b/server/src/services/apps/models.rs @@ -1,14 +1,16 @@ -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::core::public_id::{AppId, TenantId}; + // ── Database Document ── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct App { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, + pub id: AppId, + pub tenant_id: TenantId, /// "ios" or "android". pub platform: String, /// iOS bundle ID (e.g. "com.example.myapp"). @@ -73,8 +75,7 @@ pub struct CreateAppRequest { #[derive(Debug, Serialize, ToSchema)] pub struct AppDetail { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: AppId, #[schema(example = "ios")] pub platform: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/server/src/services/apps/repo.rs b/server/src/services/apps/repo.rs index ae6275b..eadf12c 100644 --- a/server/src/services/apps/repo.rs +++ b/server/src/services/apps/repo.rs @@ -73,7 +73,7 @@ impl AppsRepository for AppsRepo { .map_err(|e| e.to_string())?; // Re-fetch so we return the actual document (correct _id and created_at). - self.find_by_tenant_platform(&app.tenant_id, &app.platform) + self.find_by_tenant_platform(app.tenant_id.as_object_id(), &app.platform) .await? .ok_or_else(|| "App not found after upsert".to_string()) } diff --git a/server/src/services/auth/oauth/models.rs b/server/src/services/auth/oauth/models.rs index 3017926..633416e 100644 --- a/server/src/services/auth/oauth/models.rs +++ b/server/src/services/auth/oauth/models.rs @@ -1,7 +1,7 @@ //! Data types for `services/auth/oauth/` — provider enum, errors, config //! holder, and service return shapes. -use mongodb::bson::oid::ObjectId; +use crate::core::public_id::{TenantId, UserId}; use std::fmt; // ── Provider ── @@ -93,8 +93,8 @@ pub struct OauthStartOutcome { /// the session cookie, exactly like the magic-link callback. #[derive(Debug, Clone)] pub struct OauthCallbackOutcome { - pub user_id: ObjectId, - pub tenant_id: ObjectId, + pub user_id: UserId, + pub tenant_id: TenantId, /// Sanitized same-origin path on `origin` (e.g. `/account` or /// `/account?from=oauth`). Already passed `sanitize_next` at start time. pub next: String, diff --git a/server/src/services/auth/oauth/service.rs b/server/src/services/auth/oauth/service.rs index 6049ab4..447cfd9 100644 --- a/server/src/services/auth/oauth/service.rs +++ b/server/src/services/auth/oauth/service.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use std::time::Duration; -use mongodb::bson::{doc, oid::ObjectId}; +use mongodb::bson::doc; use rand::Rng; use reqwest::Client; use sha2::{Digest, Sha256}; @@ -227,7 +227,7 @@ impl OauthService { .map_err(OauthError::Internal)? { Some(user) => { - let user_id = user.id.unwrap_or_else(ObjectId::new); + let user_id = user.id.unwrap_or_else(crate::core::public_id::UserId::new); if !user.verified { let _ = self.users_repo.mark_verified(&info.email).await; } diff --git a/server/src/services/auth/permissions/context.rs b/server/src/services/auth/permissions/context.rs index 8521972..30fa814 100644 --- a/server/src/services/auth/permissions/context.rs +++ b/server/src/services/auth/permissions/context.rs @@ -2,15 +2,14 @@ //! helpers. Implementation file; `pub` data types live in `models.rs`. use super::models::{AuthContext, AuthzError, Permission, Principal, ResourceScope, Scopes}; -use crate::core::public_id::{TenantId, UserId}; +use crate::core::public_id::{AuthSessionId, SecretKeyId, TenantId, UserId}; use crate::services::auth::secret_keys::repo::KeyScope; -use mongodb::bson::oid::ObjectId; use std::collections::BTreeSet; impl AuthContext { /// Build context for a session-authenticated request. Sessions are always /// full tenant access — there's no affiliate-scoped human in Phase 1. - pub fn for_session(tenant_id: TenantId, user_id: UserId, session_id: ObjectId) -> Self { + pub fn for_session(tenant_id: TenantId, user_id: UserId, session_id: AuthSessionId) -> Self { Self { tenant_id, principal: Principal::User { @@ -27,7 +26,7 @@ impl AuthContext { /// rule as `services/auth/scope::require_full`. pub fn for_secret_key( tenant_id: TenantId, - key_id: ObjectId, + key_id: SecretKeyId, key_scope: Option<&KeyScope>, ) -> Self { let (permissions, resource_scope) = match key_scope { @@ -35,9 +34,7 @@ impl AuthContext { Some(KeyScope::Affiliate { affiliate_id }) => ( Scopes::affiliate_partner(), ResourceScope::Affiliate { - affiliate_id: crate::core::public_id::AffiliateId::from_object_id( - *affiliate_id, - ), + affiliate_id: *affiliate_id, }, ), }; diff --git a/server/src/services/auth/permissions/context_tests.rs b/server/src/services/auth/permissions/context_tests.rs index 6cc53bb..5a8c853 100644 --- a/server/src/services/auth/permissions/context_tests.rs +++ b/server/src/services/auth/permissions/context_tests.rs @@ -1,10 +1,13 @@ use super::super::models::{AuthContext, AuthzError, Permission, Principal, ResourceScope, Scopes}; use crate::core::public_id::{TenantId, UserId}; use crate::services::auth::secret_keys::repo::KeyScope; -use mongodb::bson::oid::ObjectId; fn user_ctx() -> AuthContext { - AuthContext::for_session(TenantId::new(), UserId::new(), ObjectId::new()) + AuthContext::for_session( + TenantId::new(), + UserId::new(), + crate::core::public_id::AuthSessionId::new(), + ) } #[test] @@ -17,22 +20,30 @@ fn session_has_full_scope() { #[test] fn secret_key_full_has_full_scope() { - let ctx = AuthContext::for_secret_key(TenantId::new(), ObjectId::new(), Some(&KeyScope::Full)); + let ctx = AuthContext::for_secret_key( + TenantId::new(), + crate::core::public_id::SecretKeyId::new(), + Some(&KeyScope::Full), + ); assert!(ctx.require(Permission::AffiliatesWrite).is_ok()); } #[test] fn secret_key_missing_scope_grandfathered_to_full() { - let ctx = AuthContext::for_secret_key(TenantId::new(), ObjectId::new(), None); + let ctx = AuthContext::for_secret_key( + TenantId::new(), + crate::core::public_id::SecretKeyId::new(), + None, + ); assert!(ctx.require(Permission::WebhooksWrite).is_ok()); } #[test] fn secret_key_affiliate_has_only_links_scope() { - let affiliate_id = ObjectId::new(); + let affiliate_id = crate::core::public_id::AffiliateId::new(); let ctx = AuthContext::for_secret_key( TenantId::new(), - ObjectId::new(), + crate::core::public_id::SecretKeyId::new(), Some(&KeyScope::Affiliate { affiliate_id }), ); assert!(ctx.require(Permission::LinksRead).is_ok()); @@ -43,7 +54,7 @@ fn secret_key_affiliate_has_only_links_scope() { ); assert!(matches!( ctx.resource_scope, - ResourceScope::Affiliate { affiliate_id: a } if a.to_object_id() == affiliate_id + ResourceScope::Affiliate { affiliate_id: a } if a == affiliate_id )); } @@ -51,9 +62,9 @@ fn secret_key_affiliate_has_only_links_scope() { fn require_any_succeeds_if_one_matches() { let ctx = AuthContext::for_secret_key( TenantId::new(), - ObjectId::new(), + crate::core::public_id::SecretKeyId::new(), Some(&KeyScope::Affiliate { - affiliate_id: ObjectId::new(), + affiliate_id: crate::core::public_id::AffiliateId::new(), }), ); assert!(ctx @@ -65,9 +76,9 @@ fn require_any_succeeds_if_one_matches() { fn require_any_fails_when_none_match() { let ctx = AuthContext::for_secret_key( TenantId::new(), - ObjectId::new(), + crate::core::public_id::SecretKeyId::new(), Some(&KeyScope::Affiliate { - affiliate_id: ObjectId::new(), + affiliate_id: crate::core::public_id::AffiliateId::new(), }), ); let err = ctx @@ -81,7 +92,11 @@ fn principal_carries_correct_kind() { let session = user_ctx(); assert!(matches!(session.principal, Principal::User { .. })); - let key = AuthContext::for_secret_key(TenantId::new(), ObjectId::new(), Some(&KeyScope::Full)); + let key = AuthContext::for_secret_key( + TenantId::new(), + crate::core::public_id::SecretKeyId::new(), + Some(&KeyScope::Full), + ); assert!(matches!(key.principal, Principal::SecretKey { .. })); } diff --git a/server/src/services/auth/permissions/models.rs b/server/src/services/auth/permissions/models.rs index 998a522..28e0cbe 100644 --- a/server/src/services/auth/permissions/models.rs +++ b/server/src/services/auth/permissions/models.rs @@ -1,10 +1,9 @@ //! Data types for service-layer authorization. -use mongodb::bson::oid::ObjectId; use std::collections::BTreeSet; use std::fmt; -use crate::core::public_id::{AffiliateId, TenantId, UserId}; +use crate::core::public_id::{AffiliateId, AuthSessionId, SecretKeyId, TenantId, UserId}; /// Closed set of operation types a caller can be authorized for. Wire /// representation is `:` (see `to_wire_str`) — used in @@ -72,11 +71,10 @@ pub enum Principal { /// Browser/dashboard session. User { user_id: UserId, - /// Session id — still an ObjectId until the sessions resource migrates. - session_id: ObjectId, + session_id: AuthSessionId, }, - /// `rl_live_…` secret key. `key_id` stays ObjectId until secret_keys migrates. - SecretKey { key_id: ObjectId }, + /// `rl_live_…` secret key. + SecretKey { key_id: SecretKeyId }, } /// Which subset of the tenant's resources the caller can act on. Distinct diff --git a/server/src/services/auth/publishable_keys/models.rs b/server/src/services/auth/publishable_keys/models.rs index a263043..c22fb6a 100644 --- a/server/src/services/auth/publishable_keys/models.rs +++ b/server/src/services/auth/publishable_keys/models.rs @@ -1,14 +1,16 @@ -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::core::public_id::{PublishableKeyId, TenantId}; + // ── Database Document ── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SdkKeyDoc { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, + pub id: PublishableKeyId, + pub tenant_id: TenantId, pub key_hash: String, pub key_prefix: String, pub domain: String, @@ -27,7 +29,7 @@ pub struct CreateSdkKeyRequest { #[derive(Debug, Serialize, ToSchema)] pub struct CreateSdkKeyResponse { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + #[schema(example = "pkid_665a1b2c3d4e5f6a7b8c9d0e")] pub id: String, /// The full SDK key. Shown only once at creation time. #[schema(example = "pk_live_a1b2c3d4e5f6g7h8i9j0")] @@ -40,7 +42,7 @@ pub struct CreateSdkKeyResponse { #[derive(Debug, Serialize, ToSchema)] pub struct SdkKeyDetail { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + #[schema(example = "pkid_665a1b2c3d4e5f6a7b8c9d0e")] pub id: String, #[schema(example = "pk_live_a1b2")] pub key_prefix: String, diff --git a/server/src/services/auth/secret_keys/models.rs b/server/src/services/auth/secret_keys/models.rs index a689161..0153a3b 100644 --- a/server/src/services/auth/secret_keys/models.rs +++ b/server/src/services/auth/secret_keys/models.rs @@ -1,7 +1,9 @@ -use mongodb::bson::{self, oid::ObjectId}; +use mongodb::bson; use serde::{Deserialize, Serialize}; use std::fmt; +use crate::core::public_id::{AffiliateId, SecretKeyId, TenantId, UserId}; + /// Stored secret key (`rl_live_…`). /// /// `scope` is optional only as a migration-window concession — pre-existing @@ -12,9 +14,9 @@ use std::fmt; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecretKeyDoc { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, - pub created_by: ObjectId, + pub id: SecretKeyId, + pub tenant_id: TenantId, + pub created_by: UserId, pub key_hash: String, pub key_prefix: String, pub created_at: bson::DateTime, @@ -36,7 +38,7 @@ pub enum KeyScope { /// Partner-scoped access. Key can only operate on the named affiliate's /// links (mint pinned to this id, read its own links). Cannot manage /// tenant resources. - Affiliate { affiliate_id: ObjectId }, + Affiliate { affiliate_id: AffiliateId }, } // ── Errors ── @@ -112,15 +114,15 @@ impl SecretKeyError { // ── Service return types ── pub struct CreatedKey { - pub id: ObjectId, + pub id: SecretKeyId, pub key: String, pub key_prefix: String, pub created_at: bson::DateTime, } pub struct KeyDetail { - pub id: ObjectId, + pub id: SecretKeyId, pub key_prefix: String, - pub created_by: ObjectId, + pub created_by: UserId, pub created_at: bson::DateTime, } diff --git a/server/src/services/auth/secret_keys/service.rs b/server/src/services/auth/secret_keys/service.rs index a861cf4..5879c92 100644 --- a/server/src/services/auth/secret_keys/service.rs +++ b/server/src/services/auth/secret_keys/service.rs @@ -1,10 +1,11 @@ -use mongodb::bson::{doc, oid::ObjectId}; +use mongodb::bson::doc; use rift_macros::requires; use std::sync::Arc; use super::models::{CreatedKey, KeyDetail, KeyScope, SecretKeyDoc, SecretKeyError}; use super::repo::SecretKeysRepository; use crate::core::email; +use crate::core::public_id::{SecretKeyId, TenantId, UserId}; use crate::services::auth::keys; use crate::services::auth::permissions::{AuthContext, Permission, Principal}; use crate::services::auth::users::repo::UsersRepository; @@ -17,8 +18,8 @@ use crate::services::tokens::{ConsumeOutcome, TokenKind, TokenPurpose, TokenServ // Mints with `KeyScope::Full`; affiliate-scoped keys go through `mint_scoped` below. pub async fn mint_for_tenant( sk_repo: &dyn SecretKeysRepository, - tenant_id: ObjectId, - created_by: ObjectId, + tenant_id: TenantId, + created_by: UserId, ) -> Result { mint_scoped(sk_repo, tenant_id, created_by, KeyScope::Full).await } @@ -29,12 +30,12 @@ pub async fn mint_for_tenant( /// for partner credentials provisioned via `POST /v1/affiliates/{id}/credentials`. pub async fn mint_scoped( sk_repo: &dyn SecretKeysRepository, - tenant_id: ObjectId, - created_by: ObjectId, + tenant_id: TenantId, + created_by: UserId, scope: KeyScope, ) -> Result { let (full_key, key_hash, key_prefix) = keys::generate_api_key(); - let key_id = ObjectId::new(); + let key_id = SecretKeyId::new(); let now = mongodb::bson::DateTime::now(); let key_doc = SecretKeyDoc { @@ -108,7 +109,7 @@ impl SecretKeysService { return Err(SecretKeyError::UserUnverified); } - let user_id = user.id.unwrap_or_else(ObjectId::new); + let user_id = user.id.unwrap_or_else(UserId::new); // Key limit. let count = self @@ -212,9 +213,13 @@ impl SecretKeysService { return Err(SecretKeyError::InvalidCode); } - mint_for_tenant(&*self.sk_repo, meta_tenant, meta_user) - .await - .map_err(SecretKeyError::Internal) + mint_for_tenant( + &*self.sk_repo, + TenantId::from_object_id(meta_tenant), + UserId::from_object_id(meta_user), + ) + .await + .map_err(SecretKeyError::Internal) } ConsumeOutcome::Ok { .. } => Err(SecretKeyError::InvalidCode), } @@ -248,7 +253,11 @@ impl SecretKeysService { /// next request would 401. Session-authed callers carry /// `Principal::User { .. }` so `SelfDelete` is structurally impossible. #[requires(Permission::SecretKeysWrite)] - pub async fn delete(&self, ctx: &AuthContext, key_id: ObjectId) -> Result<(), SecretKeyError> { + pub async fn delete( + &self, + ctx: &AuthContext, + key_id: SecretKeyId, + ) -> Result<(), SecretKeyError> { if let Principal::SecretKey { key_id: auth_key_id, } = ctx.principal @@ -270,7 +279,7 @@ impl SecretKeysService { let deleted = self .sk_repo - .delete_key(ctx.tenant_id.as_object_id(), &key_id) + .delete_key(ctx.tenant_id.as_object_id(), key_id.as_object_id()) .await .map_err(SecretKeyError::Internal)?; @@ -305,12 +314,8 @@ impl SecretKeysService { return Err(SecretKeyError::KeyLimit); } - mint_for_tenant( - &*self.sk_repo, - ctx.tenant_id.to_object_id(), - user_id.to_object_id(), - ) - .await - .map_err(SecretKeyError::Internal) + mint_for_tenant(&*self.sk_repo, ctx.tenant_id, user_id) + .await + .map_err(SecretKeyError::Internal) } } diff --git a/server/src/services/auth/sessions/models.rs b/server/src/services/auth/sessions/models.rs index 6addf34..eb88e2a 100644 --- a/server/src/services/auth/sessions/models.rs +++ b/server/src/services/auth/sessions/models.rs @@ -1,10 +1,12 @@ //! Data types for `services/auth/sessions/` — DB document, error enum, //! service config + return types. -use mongodb::bson::{self, oid::ObjectId}; +use mongodb::bson; use serde::{Deserialize, Serialize}; use std::fmt; +use crate::core::public_id::{AuthSessionId, TenantId, UserId}; + // ── DB Document ── /// One row in the `sessions` collection. Represents a human signed into a @@ -12,9 +14,9 @@ use std::fmt; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionDoc { #[serde(rename = "_id")] - pub id: ObjectId, - pub user_id: ObjectId, - pub tenant_id: ObjectId, + pub id: AuthSessionId, + pub user_id: UserId, + pub tenant_id: TenantId, /// SHA-256 of the raw opaque token. The raw token only ever exists in the /// `Set-Cookie` header and in the client browser. pub token_hash: String, @@ -44,9 +46,9 @@ pub struct SessionsConfig { /// Resolved session lookup — what session middleware injects. #[derive(Debug, Clone)] pub struct ResolvedSession { - pub session_id: ObjectId, - pub user_id: ObjectId, - pub tenant_id: ObjectId, + pub session_id: AuthSessionId, + pub user_id: UserId, + pub tenant_id: TenantId, } /// Returned from `consume_sign_in` — the raw cookie value to set, the @@ -55,8 +57,8 @@ pub struct ResolvedSession { /// `OriginMatcher` allowlist). pub struct SignInOutcome { pub raw_token: String, - pub user_id: ObjectId, - pub tenant_id: ObjectId, + pub user_id: UserId, + pub tenant_id: TenantId, pub origin: Option, /// Same-origin path captured at signin time and validated against /// the request's `Origin` (or `marketing_url`). The callback prefers diff --git a/server/src/services/auth/sessions/service.rs b/server/src/services/auth/sessions/service.rs index e604bba..0ae8ab5 100644 --- a/server/src/services/auth/sessions/service.rs +++ b/server/src/services/auth/sessions/service.rs @@ -12,7 +12,7 @@ use std::sync::Arc; -use mongodb::bson::{doc, oid::ObjectId}; +use mongodb::bson::doc; use rand::Rng; use sha2::{Digest, Sha256}; @@ -217,7 +217,7 @@ impl SessionsService { .map_err(SessionError::Internal)? { Some(user) => { - let user_id = user.id.unwrap_or_else(ObjectId::new); + let user_id = user.id.unwrap_or_else(crate::core::public_id::UserId::new); // Email click is proof of ownership — bump verified if it // wasn't already. We don't surface a failure if mark_verified // returns None here because the find_by_email above just @@ -264,8 +264,8 @@ impl SessionsService { /// upstreams but produce the same session row + cookie shape. pub async fn issue_session( &self, - user_id: ObjectId, - tenant_id: ObjectId, + user_id: crate::core::public_id::UserId, + tenant_id: crate::core::public_id::TenantId, client_ip: Option<&str>, user_agent: Option<&str>, ) -> Result { @@ -277,7 +277,7 @@ impl SessionsService { ); let session_doc = SessionDoc { - id: ObjectId::new(), + id: crate::core::public_id::AuthSessionId::new(), user_id, tenant_id, token_hash, @@ -317,7 +317,10 @@ impl SessionsService { let staleness_secs = mongodb::bson::DateTime::now().timestamp_millis() / 1000 - session.last_seen_at.timestamp_millis() / 1000; if staleness_secs > Self::TOUCH_INTERVAL_SECS { - let _ = self.sessions_repo.touch_last_seen(&session.id).await; + let _ = self + .sessions_repo + .touch_last_seen(&session.id.to_object_id()) + .await; } Ok(Some(ResolvedSession { @@ -328,9 +331,12 @@ impl SessionsService { } /// Revoke a session by id (called from `POST /v1/auth/signout`). Idempotent. - pub async fn revoke(&self, session_id: &ObjectId) -> Result<(), SessionError> { + pub async fn revoke( + &self, + session_id: &crate::core::public_id::AuthSessionId, + ) -> Result<(), SessionError> { self.sessions_repo - .revoke(session_id) + .revoke(&session_id.to_object_id()) .await .map(|_| ()) .map_err(SessionError::Internal) diff --git a/server/src/services/auth/tenants/models.rs b/server/src/services/auth/tenants/models.rs index 7797d83..ac8e753 100644 --- a/server/src/services/auth/tenants/models.rs +++ b/server/src/services/auth/tenants/models.rs @@ -1,9 +1,11 @@ //! Data types for `services/auth/tenants/` — DB document, plan/billing enums, //! and update payloads. -use mongodb::bson::{self, oid::ObjectId}; +use mongodb::bson; use serde::{Deserialize, Serialize}; +use crate::core::public_id::TenantId; + // ── Plan / billing enums ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)] @@ -40,7 +42,7 @@ pub enum SubscriptionStatus { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TenantDoc { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, + pub id: Option, pub monthly_quota: i64, pub created_at: bson::DateTime, diff --git a/server/src/services/auth/tenants/service.rs b/server/src/services/auth/tenants/service.rs index 9a9e659..16ebd50 100644 --- a/server/src/services/auth/tenants/service.rs +++ b/server/src/services/auth/tenants/service.rs @@ -1,4 +1,3 @@ -use mongodb::bson::oid::ObjectId; use std::sync::Arc; use super::repo::{TenantDoc, TenantsRepository}; @@ -19,8 +18,8 @@ impl TenantsService { /// Create a bare tenant with default limits and return its id. Callers are /// responsible for attaching an owner (email user, wallet credential, etc.) /// immediately after. - pub async fn create_blank(&self) -> Result { - let id = ObjectId::new(); + pub async fn create_blank(&self) -> Result { + let id = crate::core::public_id::TenantId::new(); let doc = TenantDoc { id: Some(id), ..TenantDoc::default() diff --git a/server/src/services/auth/usage/models.rs b/server/src/services/auth/usage/models.rs index be12ae9..6f445d9 100644 --- a/server/src/services/auth/usage/models.rs +++ b/server/src/services/auth/usage/models.rs @@ -1,13 +1,25 @@ //! Data types for `services/auth/usage/` — request usage logging document. -use mongodb::bson::{self, oid::ObjectId}; +use mongodb::bson; use serde::{Deserialize, Serialize}; +use crate::core::public_id::SecretKeyId; + +// Use a separate marker for usage rows since the `_id` is an internal log row id, +// not a tenant/user/etc. identifier. +crate::impl_container!(UsageRowIdMarker); +pub struct UsageRowIdMarker; +impl crate::core::public_id::IdPrefix for UsageRowIdMarker { + const PREFIX: &'static str = "usage"; + const SCHEMA_NAME: &'static str = "UsageRowId"; +} +pub type UsageRowId = crate::core::public_id::Id; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UsageDoc { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, - pub api_key_id: Option, + pub id: Option, + pub api_key_id: Option, pub ip: String, pub endpoint: String, pub ts: bson::DateTime, diff --git a/server/src/services/auth/users/models.rs b/server/src/services/auth/users/models.rs index 8c5c0ba..f64b9a7 100644 --- a/server/src/services/auth/users/models.rs +++ b/server/src/services/auth/users/models.rs @@ -1,10 +1,11 @@ //! Data types for `services/auth/users/` — DB document, error enum, service //! return types. -use mongodb::bson::{self, oid::ObjectId}; +use mongodb::bson; use serde::{Deserialize, Serialize}; use std::fmt; +use crate::core::public_id::{TenantId, UserId}; use crate::services::auth::permissions::AuthzError; use crate::services::billing::quota::QuotaError; @@ -13,8 +14,8 @@ use crate::services::billing::quota::QuotaError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserDoc { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, - pub tenant_id: ObjectId, + pub id: Option, + pub tenant_id: TenantId, pub email: String, pub verified: bool, pub is_owner: bool, @@ -80,17 +81,17 @@ impl UserError { // ── Service return types ── pub struct VerifyResult { - pub tenant_id: ObjectId, + pub tenant_id: TenantId, pub email: String, } pub struct InviteResult { - pub user_id: ObjectId, + pub user_id: UserId, pub email: String, } pub struct UserDetail { - pub id: ObjectId, + pub id: UserId, pub email: String, pub verified: bool, pub is_owner: bool, diff --git a/server/src/services/auth/users/service.rs b/server/src/services/auth/users/service.rs index 3ad5d2d..4b7db67 100644 --- a/server/src/services/auth/users/service.rs +++ b/server/src/services/auth/users/service.rs @@ -1,4 +1,4 @@ -use mongodb::bson::{doc, oid::ObjectId}; +use mongodb::bson::doc; use rift_macros::requires; use std::sync::Arc; @@ -51,7 +51,13 @@ impl UsersService { pub async fn create_tenant_with_verified_owner( &self, email: &str, - ) -> Result<(ObjectId, ObjectId), UserError> { + ) -> Result< + ( + crate::core::public_id::TenantId, + crate::core::public_id::UserId, + ), + UserError, + > { let email = validate_email(email).map_err(|_| UserError::InvalidEmail)?; let tenant_id = self @@ -60,7 +66,7 @@ impl UsersService { .await .map_err(UserError::Internal)?; - let user_id = ObjectId::new(); + let user_id = crate::core::public_id::UserId::new(); let user_doc = UserDoc { id: Some(user_id), tenant_id, @@ -140,14 +146,13 @@ impl UsersService { // Service-layer quota enforcement (applies to every transport). if let Some(q) = &self.quota { - q.check(ctx.tenant_id.as_object_id(), Resource::InviteTeamMember) - .await?; + q.check(&ctx.tenant_id, Resource::InviteTeamMember).await?; } - let user_id = ObjectId::new(); + let user_id = crate::core::public_id::UserId::new(); let user_doc = UserDoc { id: Some(user_id), - tenant_id: ctx.tenant_id.to_object_id(), + tenant_id: ctx.tenant_id, email: email.clone(), verified: false, is_owner: false, @@ -208,7 +213,7 @@ impl UsersService { Ok(docs .into_iter() .map(|d| UserDetail { - id: d.id.unwrap_or_else(ObjectId::new), + id: d.id.unwrap_or_else(crate::core::public_id::UserId::new), email: d.email, verified: d.verified, is_owner: d.is_owner, @@ -219,7 +224,11 @@ impl UsersService { /// Delete a user. Guard: can't remove last verified user. #[requires(Permission::TenantAdmin)] - pub async fn delete(&self, ctx: &AuthContext, user_id: ObjectId) -> Result<(), UserError> { + pub async fn delete( + &self, + ctx: &AuthContext, + user_id: crate::core::public_id::UserId, + ) -> Result<(), UserError> { let count = self .users_repo .count_verified_by_tenant(ctx.tenant_id.as_object_id()) @@ -232,7 +241,7 @@ impl UsersService { let deleted = self .users_repo - .delete(ctx.tenant_id.as_object_id(), &user_id) + .delete(ctx.tenant_id.as_object_id(), &user_id.to_object_id()) .await .map_err(UserError::Internal)?; diff --git a/server/src/services/billing/models.rs b/server/src/services/billing/models.rs index 71ace8a..5b6f158 100644 --- a/server/src/services/billing/models.rs +++ b/server/src/services/billing/models.rs @@ -148,7 +148,7 @@ pub struct PlanLimits { pub struct EventCounterDoc { #[serde(rename = "_id")] pub id: String, - pub tenant_id: mongodb::bson::oid::ObjectId, + pub tenant_id: crate::core::public_id::TenantId, pub period: String, // e.g. "2026-04" pub count: i64, pub created_at: bson::DateTime, diff --git a/server/src/services/billing/quota.rs b/server/src/services/billing/quota.rs index 2fad69b..795686a 100644 --- a/server/src/services/billing/quota.rs +++ b/server/src/services/billing/quota.rs @@ -1,12 +1,12 @@ use async_trait::async_trait; use chrono::{Datelike, Utc}; -use mongodb::bson::oid::ObjectId; use std::sync::Arc; use super::limits::{limits_for, PlanLimits}; use super::models::BillingError; use super::repos::event_counters::EventCountersRepository; use super::service::TierResolver; +use crate::core::public_id::TenantId; // Re-export quota data types from models.rs so existing callers (which import // via `services::billing::quota::{...}`) keep compiling. The data types have @@ -26,7 +26,7 @@ pub use super::models::{EnforcementMode, QuotaError, Resource}; /// defined later in this file and gated behind the `test-harness` feature. #[async_trait] pub trait QuotaChecker: Send + Sync { - async fn check(&self, tenant_id: &ObjectId, resource: Resource) -> Result<(), QuotaError>; + async fn check(&self, tenant_id: &TenantId, resource: Resource) -> Result<(), QuotaError>; /// Pre-check whether `n` units of `resource` would fit. Used by bulk /// operations (e.g. `POST /v1/links/bulk`) so the whole batch is gated @@ -35,7 +35,7 @@ pub trait QuotaChecker: Send + Sync { /// `QuotaService` overrides with one comparison. async fn check_n( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, resource: Resource, n: u64, ) -> Result<(), QuotaError> { @@ -51,7 +51,7 @@ pub trait QuotaChecker: Send + Sync { /// existing repo already has (or gets) a `count_by_tenant` for exactly this. #[async_trait::async_trait] pub trait ResourceCounts: Send + Sync { - async fn count(&self, tenant_id: &ObjectId, resource: Resource) -> Result; + async fn count(&self, tenant_id: &TenantId, resource: Resource) -> Result; } crate::impl_container!(QuotaService); @@ -87,7 +87,8 @@ impl QuotaChecker for QuotaService { /// - `LogOnly`: returns `Ok(())` always, logs would-be rejections. /// - `Enforce`: returns `Err(QuotaError::Exceeded { ... })` when over /// limit. Caller renders as `402 Payment Required`. - async fn check(&self, tenant_id: &ObjectId, resource: Resource) -> Result<(), QuotaError> { + async fn check(&self, tenant_id: &TenantId, resource: Resource) -> Result<(), QuotaError> { + let oid = tenant_id.as_object_id(); let tier = self.billing.effective_tier(tenant_id).await?; let limits = limits_for(tier); let max = match limit_for_resource(&limits, resource) { @@ -100,7 +101,7 @@ impl QuotaChecker for QuotaService { let period = current_period(); let within = self .counters - .increment_if_below(tenant_id, &period, Some(max)) + .increment_if_below(oid, &period, Some(max)) .await .map_err(|e| QuotaError::Billing(BillingError::Internal(e)))?; if within { @@ -149,7 +150,7 @@ impl QuotaChecker for QuotaService { async fn check_n( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, resource: Resource, n: u64, ) -> Result<(), QuotaError> { @@ -235,7 +236,7 @@ pub struct NoopQuotaChecker; #[cfg(any(test, feature = "test-harness"))] #[async_trait] impl QuotaChecker for NoopQuotaChecker { - async fn check(&self, _tenant_id: &ObjectId, _resource: Resource) -> Result<(), QuotaError> { + async fn check(&self, _tenant_id: &TenantId, _resource: Resource) -> Result<(), QuotaError> { Ok(()) } } @@ -249,7 +250,7 @@ pub struct DenyQuotaChecker { #[cfg(any(test, feature = "test-harness"))] #[async_trait] impl QuotaChecker for DenyQuotaChecker { - async fn check(&self, _tenant_id: &ObjectId, resource: Resource) -> Result<(), QuotaError> { + async fn check(&self, _tenant_id: &TenantId, resource: Resource) -> Result<(), QuotaError> { Err(QuotaError::Exceeded { resource, limit: self.limit, diff --git a/server/src/services/billing/quota_tests.rs b/server/src/services/billing/quota_tests.rs index ab62e0f..8a22fce 100644 --- a/server/src/services/billing/quota_tests.rs +++ b/server/src/services/billing/quota_tests.rs @@ -1,7 +1,9 @@ use super::*; +use crate::core::public_id::TenantId; use crate::services::auth::tenants::repo::{PlanTier, TenantDoc, TenantsRepository}; use crate::services::billing::service::BillingService; use async_trait::async_trait; +use mongodb::bson::oid::ObjectId; use std::sync::Mutex; #[derive(Default)] @@ -21,7 +23,7 @@ impl TenantsRepository for MockTenants { .lock() .unwrap() .iter() - .find(|t| t.id.as_ref() == Some(id)) + .find(|t| t.id.map(|i| i.to_object_id()).as_ref() == Some(id)) .cloned()) } async fn find_by_stripe_customer_id( @@ -55,7 +57,7 @@ impl MockCounts { #[async_trait] impl ResourceCounts for MockCounts { - async fn count(&self, _tenant_id: &ObjectId, resource: Resource) -> Result { + async fn count(&self, _tenant_id: &TenantId, resource: Resource) -> Result { Ok(*self .counts .lock() @@ -96,9 +98,9 @@ impl EventCountersRepository for MockCounters { async fn setup_with_plan_mode( plan: PlanTier, mode: EnforcementMode, -) -> (QuotaService, ObjectId, Arc, Arc) { +) -> (QuotaService, TenantId, Arc, Arc) { let tenants = Arc::new(MockTenants::default()); - let id = ObjectId::new(); + let id = TenantId::new(); tenants .create(&TenantDoc { id: Some(id), @@ -123,7 +125,7 @@ async fn setup_with_plan_mode( async fn setup_with_plan( plan: PlanTier, -) -> (QuotaService, ObjectId, Arc, Arc) { +) -> (QuotaService, TenantId, Arc, Arc) { setup_with_plan_mode(plan, EnforcementMode::LogOnly).await } @@ -196,14 +198,14 @@ async fn track_event_uses_atomic_counter() { #[tokio::test] async fn unknown_tenant_propagates_billing_error() { let (q, _, _, _) = setup_with_plan(PlanTier::Free).await; - let err = q.check(&ObjectId::new(), Resource::CreateLink).await; + let err = q.check(&TenantId::new(), Resource::CreateLink).await; assert!(matches!(err, Err(QuotaError::Billing(_)))); } #[tokio::test] async fn noop_checker_always_ok() { let c = NoopQuotaChecker; - c.check(&ObjectId::new(), Resource::CreateLink) + c.check(&TenantId::new(), Resource::CreateLink) .await .unwrap(); } @@ -212,7 +214,7 @@ async fn noop_checker_always_ok() { async fn deny_checker_always_errs() { let c = DenyQuotaChecker { limit: 42 }; let err = c - .check(&ObjectId::new(), Resource::CreateLink) + .check(&TenantId::new(), Resource::CreateLink) .await .unwrap_err(); assert!(matches!( diff --git a/server/src/services/billing/repos/resource_counts_adapter.rs b/server/src/services/billing/repos/resource_counts_adapter.rs index e548352..01b7b15 100644 --- a/server/src/services/billing/repos/resource_counts_adapter.rs +++ b/server/src/services/billing/repos/resource_counts_adapter.rs @@ -3,10 +3,10 @@ //! from taking a fan-out of repo dependencies directly. use async_trait::async_trait; -use mongodb::bson::oid::ObjectId; use std::sync::Arc; use super::super::quota::{Resource, ResourceCounts}; +use crate::core::public_id::TenantId; use crate::services::affiliates::repo::AffiliatesRepository; use crate::services::auth::users::repo::UsersRepository; use crate::services::domains::repo::DomainsRepository; @@ -24,23 +24,18 @@ pub struct RepoResourceCounts { #[async_trait] impl ResourceCounts for RepoResourceCounts { - async fn count(&self, tenant_id: &ObjectId, resource: Resource) -> Result { + async fn count(&self, tenant_id: &TenantId, resource: Resource) -> Result { + let oid = tenant_id.as_object_id(); match resource { - Resource::CreateLink => self.links.count_links_by_tenant(tenant_id).await, - Resource::CreateDomain => self.domains.count_by_tenant(tenant_id).await, + Resource::CreateLink => self.links.count_links_by_tenant(oid).await, + Resource::CreateDomain => self.domains.count_by_tenant(oid).await, Resource::InviteTeamMember => self .users - .count_verified_by_tenant(tenant_id) + .count_verified_by_tenant(oid) .await .map(|n| n as u64), - Resource::CreateWebhook => self.webhooks.count_by_tenant(tenant_id).await, - Resource::CreateAffiliate => { - self.affiliates - .count_by_tenant(&crate::core::public_id::TenantId::from_object_id( - *tenant_id, - )) - .await - } + Resource::CreateWebhook => self.webhooks.count_by_tenant(oid).await, + Resource::CreateAffiliate => self.affiliates.count_by_tenant(tenant_id).await, // TrackEvent uses the atomic counter path, not ResourceCounts. Resource::TrackEvent => Ok(0), } diff --git a/server/src/services/billing/service.rs b/server/src/services/billing/service.rs index ad6fcc5..72968f0 100644 --- a/server/src/services/billing/service.rs +++ b/server/src/services/billing/service.rs @@ -1,12 +1,12 @@ use async_trait::async_trait; use mongodb::bson; -use mongodb::bson::oid::ObjectId; use rift_macros::requires; use std::sync::Arc; use super::effective_tier::effective_tier; use super::limits::limits_for; use super::models::{BillingError, BillingStatus}; +use crate::core::public_id::TenantId; use crate::services::auth::permissions::{AuthContext, Permission}; use crate::services::auth::tenants::repo::{PlanTier, TenantsRepository}; @@ -22,8 +22,8 @@ use crate::services::auth::tenants::repo::{PlanTier, TenantsRepository}; /// fake tier data. #[async_trait] pub trait TierResolver: Send + Sync { - async fn effective_tier(&self, tenant_id: &ObjectId) -> Result; - async fn retention_bucket_for_tenant(&self, tenant_id: &ObjectId) -> &'static str; + async fn effective_tier(&self, tenant_id: &TenantId) -> Result; + async fn retention_bucket_for_tenant(&self, tenant_id: &TenantId) -> &'static str; } crate::impl_container!(BillingService); @@ -67,17 +67,17 @@ impl BillingService { // stay decoupled from BillingService's subscription-lifecycle surface. #[async_trait] impl TierResolver for BillingService { - async fn effective_tier(&self, tenant_id: &ObjectId) -> Result { + async fn effective_tier(&self, tenant_id: &TenantId) -> Result { let tenant = self .tenants_repo - .find_by_id(tenant_id) + .find_by_id(tenant_id.as_object_id()) .await .map_err(BillingError::Internal)? .ok_or(BillingError::TenantNotFound)?; Ok(effective_tier(&tenant, bson::DateTime::now())) } - async fn retention_bucket_for_tenant(&self, tenant_id: &ObjectId) -> &'static str { + async fn retention_bucket_for_tenant(&self, tenant_id: &TenantId) -> &'static str { match self.effective_tier(tenant_id).await { Ok(tier) => limits_for(tier).retention_bucket, Err(_) => "30d", diff --git a/server/src/services/billing/service_tests.rs b/server/src/services/billing/service_tests.rs index 2065edd..a2f869c 100644 --- a/server/src/services/billing/service_tests.rs +++ b/server/src/services/billing/service_tests.rs @@ -1,18 +1,16 @@ use super::*; +use crate::core::public_id::{SecretKeyId, TenantId}; use crate::services::auth::permissions::AuthContext; use crate::services::auth::secret_keys::repo::KeyScope; use crate::services::auth::tenants::repo::{PlanTier, TenantDoc}; +use mongodb::bson::oid::ObjectId; use mongodb::bson::DateTime; use async_trait::async_trait; use std::sync::Mutex; -fn full_ctx_for(tenant_id: ObjectId) -> AuthContext { - AuthContext::for_secret_key( - crate::core::public_id::TenantId::from_object_id(tenant_id), - ObjectId::new(), - Some(&KeyScope::Full), - ) +fn full_ctx_for(tenant_id: TenantId) -> AuthContext { + AuthContext::for_secret_key(tenant_id, SecretKeyId::new(), Some(&KeyScope::Full)) } #[derive(Default)] @@ -33,7 +31,7 @@ impl TenantsRepository for MockRepo { .lock() .unwrap() .iter() - .find(|t| t.id.as_ref() == Some(id)) + .find(|t| t.id.map(|i| i.to_object_id()).as_ref() == Some(id)) .cloned()) } @@ -57,7 +55,7 @@ impl TenantsRepository for MockRepo { } } -async fn setup(tenant: TenantDoc) -> (BillingService, ObjectId) { +async fn setup(tenant: TenantDoc) -> (BillingService, TenantId) { let repo = Arc::new(MockRepo::default()); let id = tenant.id.expect("test tenant needs an id"); repo.create(&tenant).await.unwrap(); @@ -67,7 +65,7 @@ async fn setup(tenant: TenantDoc) -> (BillingService, ObjectId) { #[tokio::test] async fn status_reports_free_default() { - let id = ObjectId::new(); + let id = TenantId::new(); let (svc, id) = setup(TenantDoc { id: Some(id), ..TenantDoc::default() @@ -81,7 +79,7 @@ async fn status_reports_free_default() { #[tokio::test] async fn status_reports_active_comp_as_effective_tier() { - let id = ObjectId::new(); + let id = TenantId::new(); let (svc, id) = setup(TenantDoc { id: Some(id), plan_tier: PlanTier::Free, @@ -98,7 +96,7 @@ async fn status_reports_active_comp_as_effective_tier() { #[tokio::test] async fn status_treats_expired_comp_as_inactive() { - let id = ObjectId::new(); + let id = TenantId::new(); let (svc, id) = setup(TenantDoc { id: Some(id), plan_tier: PlanTier::Pro, @@ -117,7 +115,7 @@ async fn status_missing_tenant_errors() { let repo = Arc::new(MockRepo::default()); let svc = BillingService::new(repo as Arc); let err = svc - .status(&full_ctx_for(ObjectId::new())) + .status(&full_ctx_for(TenantId::new())) .await .unwrap_err(); assert!(matches!(err, BillingError::TenantNotFound)); diff --git a/server/src/services/conversions/models.rs b/server/src/services/conversions/models.rs index eebc9f1..eb17f76 100644 --- a/server/src/services/conversions/models.rs +++ b/server/src/services/conversions/models.rs @@ -1,7 +1,18 @@ -use mongodb::bson::{oid::ObjectId, DateTime, Document}; +use mongodb::bson::{DateTime, Document}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::core::public_id::{ConversionEventId, SourceId, TenantId}; + +// Internal marker for conversion_dedup row IDs. +crate::impl_container!(ConversionDedupIdMarker); +pub struct ConversionDedupIdMarker; +impl crate::core::public_id::IdPrefix for ConversionDedupIdMarker { + const PREFIX: &'static str = "cdedup"; + const SCHEMA_NAME: &'static str = "ConversionDedupId"; +} +pub type ConversionDedupId = crate::core::public_id::Id; + // ── Source types ── /// The kind of source, which determines how incoming webhook payloads are parsed. @@ -23,8 +34,8 @@ pub enum SourceType { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Source { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, + pub id: SourceId, + pub tenant_id: TenantId, pub name: String, pub source_type: SourceType, /// 32-byte random hex — forms the public webhook URL path `POST /w/{url_token}`. @@ -46,7 +57,7 @@ pub struct ConversionEvent { /// Document identifier. Auto-generated on insert; round-tripped on /// read so `GET /v1/conversions/{id}` can fetch by it. #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, + pub id: Option, pub meta: ConversionMeta, /// Time the event occurred. For integration parsers this may be extracted from /// the upstream event (e.g. Stripe's `created`); for custom sources it defaults to now. @@ -66,13 +77,13 @@ pub struct ConversionEvent { /// are stored but less efficient to filter on. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConversionMeta { - pub tenant_id: ObjectId, + pub tenant_id: TenantId, /// Legacy field — Phase 6 stopped writing it (credit is computed at /// read time from the user's journey via `attribution_events`). Old /// rows still carry a string; new rows have `None`. #[serde(skip_serializing_if = "Option::is_none", default)] pub link_id: Option, - pub source_id: ObjectId, + pub source_id: SourceId, pub conversion_type: String, /// Retention bucket frozen at insert time — see ClickMeta for details. #[serde(default = "crate::services::links::models::default_retention_bucket")] @@ -85,8 +96,8 @@ pub struct ConversionMeta { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConversionDedup { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, + pub id: ConversionDedupId, + pub tenant_id: TenantId, pub idempotency_key: String, pub created_at: DateTime, } @@ -105,8 +116,7 @@ pub struct CreateSourceRequest { #[derive(Debug, Serialize, ToSchema)] pub struct CreateSourceResponse { - #[schema(example = "66a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: SourceId, #[schema(example = "backend-deposits")] pub name: String, pub source_type: SourceType, @@ -121,8 +131,7 @@ pub struct CreateSourceResponse { #[derive(Debug, Serialize, ToSchema)] pub struct SourceDetail { - #[schema(example = "66a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: SourceId, #[schema(example = "default")] pub name: String, pub source_type: SourceType, diff --git a/server/src/services/conversions/parsers_tests.rs b/server/src/services/conversions/parsers_tests.rs index a958ce6..144ba3e 100644 --- a/server/src/services/conversions/parsers_tests.rs +++ b/server/src/services/conversions/parsers_tests.rs @@ -1,10 +1,11 @@ use super::*; -use mongodb::bson::{oid::ObjectId, DateTime}; +use crate::core::public_id::{SourceId, TenantId}; +use mongodb::bson::DateTime; fn test_source() -> Source { Source { - id: ObjectId::new(), - tenant_id: ObjectId::new(), + id: SourceId::new(), + tenant_id: TenantId::new(), name: "test".to_string(), source_type: SourceType::Custom, url_token: "test_token".to_string(), diff --git a/server/src/services/conversions/repo.rs b/server/src/services/conversions/repo.rs index f9c0f7c..6c47ab9 100644 --- a/server/src/services/conversions/repo.rs +++ b/server/src/services/conversions/repo.rs @@ -7,8 +7,17 @@ use rand::RngCore; use crate::ensure_index; use super::models::{ConversionDedup, ConversionEvent, Source, SourceType}; +use crate::core::public_id::SourceId; use crate::services::links::models::CreditModel; +/// Sentinel `source_id` for events that came in via the SDK direct endpoint +/// rather than a registered `Source`. Stored on `ConversionMeta.source_id` so +/// the field stays non-optional in the time series schema; downstream readers +/// treat it as "no upstream source row exists." +pub fn sdk_sentinel_source_id() -> SourceId { + SourceId::from_object_id(ObjectId::from_bytes([0u8; 12])) +} + // ── Trait ── #[async_trait] @@ -195,8 +204,8 @@ impl ConversionsRepository for ConversionsRepo { source_type: SourceType, ) -> Result { let doc = Source { - id: ObjectId::new(), - tenant_id, + id: crate::core::public_id::SourceId::new(), + tenant_id: crate::core::public_id::TenantId::from_object_id(tenant_id), name, source_type, url_token: generate_url_token(), @@ -302,8 +311,8 @@ impl ConversionsRepository for ConversionsRepo { idempotency_key: &str, ) -> Result { let doc = ConversionDedup { - id: ObjectId::new(), - tenant_id: *tenant_id, + id: crate::services::conversions::models::ConversionDedupId::new(), + tenant_id: crate::core::public_id::TenantId::from_object_id(*tenant_id), idempotency_key: idempotency_key.to_string(), created_at: DateTime::now(), }; diff --git a/server/src/services/conversions/service.rs b/server/src/services/conversions/service.rs index f7e69b3..f22eca9 100644 --- a/server/src/services/conversions/service.rs +++ b/server/src/services/conversions/service.rs @@ -1,10 +1,11 @@ use std::sync::Arc; -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use super::models::ParsedConversion; use super::models::{ConversionEvent, ConversionMeta, IngestResult, Source}; -use super::repo::ConversionsRepository; +use super::repo::{sdk_sentinel_source_id, ConversionsRepository}; +use crate::core::public_id::{ConversionEventId, SourceId, TenantId}; use crate::core::webhook_dispatcher::{ConversionEventPayload, WebhookDispatcher}; use crate::services::app_users::repo::AppUsersRepository; use crate::services::billing::quota::{QuotaChecker, Resource}; @@ -62,23 +63,25 @@ impl ConversionsService { /// source_id is synthetic (the SDK is not a source — it's a direct channel). pub async fn ingest_sdk_event( &self, - tenant_id: ObjectId, + tenant_id: TenantId, parsed: Vec, ) -> IngestResult { - // Use a zero ObjectId as a sentinel for "came from SDK, not a source." + // Use a zero sentinel for "came from SDK, not a source." // This is stored in meta.source_id on the conversion event for provenance // but is not looked up as a real source document. - let sdk_source_id = ObjectId::from_bytes([0u8; 12]); - self.ingest(tenant_id, sdk_source_id, parsed).await + self.ingest(tenant_id, sdk_sentinel_source_id(), parsed) + .await } /// Core ingestion: dedup, attribute, store, fan out. async fn ingest( &self, - tenant_id: ObjectId, - source_id: ObjectId, + tenant_id: TenantId, + source_id: SourceId, parsed: Vec, ) -> IngestResult { + let tenant_oid = tenant_id.to_object_id(); + let source_oid = source_id.to_object_id(); let mut result = IngestResult::default(); for event in parsed { @@ -86,7 +89,7 @@ impl ConversionsService { if let Some(key) = &event.idempotency_key { match self .conversions_repo - .check_and_insert_dedup(&tenant_id, key) + .check_and_insert_dedup(&tenant_oid, key) .await { Ok(false) => { @@ -96,7 +99,7 @@ impl ConversionsService { Ok(true) => {} Err(e) => { tracing::error!( - source_id = %source_id, + source_id = %source_oid, key = %key, error = %e, "dedup insert failed; dropping event", @@ -114,7 +117,7 @@ impl ConversionsService { // the caller when `app_users_repo` isn't wired). let Some(user_id) = event.user_id.as_ref() else { tracing::debug!( - source_id = %source_id, + source_id = %source_oid, "conversion has no user_id; skipping", ); result.unattributed += 1; @@ -122,7 +125,7 @@ impl ConversionsService { }; let user_known = match &self.app_users_repo { Some(repo) => repo - .find_by_user_id(&tenant_id, user_id) + .find_by_user_id(&tenant_oid, user_id) .await .ok() .flatten() @@ -131,7 +134,7 @@ impl ConversionsService { }; if !user_known { tracing::debug!( - source_id = %source_id, + source_id = %source_oid, user_id = %user_id, "conversion user_id not found in app_users; skipping", ); @@ -147,7 +150,7 @@ impl ConversionsService { if let Some(q) = &self.quota { if let Err(e) = q.check(&tenant_id, Resource::TrackEvent).await { tracing::info!( - source_id = %source_id, + source_id = %source_oid, error = %e, "conversion_ingest_quota_rejected" ); @@ -162,7 +165,7 @@ impl ConversionsService { None => "30d".to_string(), }; let record = ConversionEvent { - id: Some(ObjectId::new()), + id: Some(ConversionEventId::new()), meta: ConversionMeta { tenant_id, // Credit is computed at read time; no link_id frozen @@ -182,7 +185,7 @@ impl ConversionsService { Ok(id) => id, Err(e) => { tracing::error!( - source_id = %source_id, + source_id = %source_oid, error = %e, "conversion event insert failed", ); @@ -208,7 +211,7 @@ impl ConversionsService { Some(repo) => { let ids = repo .credited_links_for_user( - &tenant_id, + &tenant_oid, user_id, event.occurred_at.unwrap_or_else(DateTime::now), ) @@ -233,8 +236,8 @@ impl ConversionsService { dispatcher.dispatch_conversion(ConversionEventPayload { event_id: event_id.to_hex(), - tenant_id: tenant_id.to_hex(), - source_id: source_id.to_hex(), + tenant_id: tenant_id.to_string(), + source_id: source_id.to_string(), conversion_type: event.conversion_type.clone(), user_id: event.user_id.clone(), first_touch_link_id: credited.first_touch_link_id, diff --git a/server/src/services/domains/models.rs b/server/src/services/domains/models.rs index 5e62e75..716de63 100644 --- a/server/src/services/domains/models.rs +++ b/server/src/services/domains/models.rs @@ -1,7 +1,9 @@ -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::core::public_id::{DomainId, TenantId}; + // ── Database Document ── /// Domain role: Primary domains serve landing pages and resolve links. @@ -20,8 +22,8 @@ pub enum DomainRole { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Domain { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, + pub id: DomainId, + pub tenant_id: TenantId, /// Fully qualified domain name (e.g. "go.tablefour.com"). pub domain: String, pub verified: bool, diff --git a/server/src/services/domains/repo.rs b/server/src/services/domains/repo.rs index 27c2b64..20c1ae0 100644 --- a/server/src/services/domains/repo.rs +++ b/server/src/services/domains/repo.rs @@ -70,8 +70,8 @@ impl DomainsRepository for DomainsRepo { role: DomainRole, ) -> Result { let doc = Domain { - id: ObjectId::new(), - tenant_id, + id: crate::core::public_id::DomainId::new(), + tenant_id: crate::core::public_id::TenantId::from_object_id(tenant_id), domain, verified: false, verification_token, diff --git a/server/src/services/domains/service.rs b/server/src/services/domains/service.rs index 53e42be..9eac234 100644 --- a/server/src/services/domains/service.rs +++ b/server/src/services/domains/service.rs @@ -35,8 +35,7 @@ impl DomainsService { role: DomainRole, ) -> Result { if let Some(q) = &self.quota { - q.check(ctx.tenant_id.as_object_id(), Resource::CreateDomain) - .await?; + q.check(&ctx.tenant_id, Resource::CreateDomain).await?; } if role == DomainRole::Alternate { diff --git a/server/src/services/install_events/models.rs b/server/src/services/install_events/models.rs index 67ffcba..2857a0e 100644 --- a/server/src/services/install_events/models.rs +++ b/server/src/services/install_events/models.rs @@ -10,7 +10,9 @@ //! collection is low-volume and identity-shaped — point lookups by //! install_id are the hot path, not time-range scans. -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; + +use crate::core::public_id::{InstallEventId, TenantId}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -58,8 +60,8 @@ pub struct InstallContext { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstallEvent { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, - pub tenant_id: ObjectId, + pub id: Option, + pub tenant_id: TenantId, pub install_id: String, pub event_type: InstallEventType, pub timestamp: DateTime, diff --git a/server/src/services/install_events/repo.rs b/server/src/services/install_events/repo.rs index 702a03d..4dc6f19 100644 --- a/server/src/services/install_events/repo.rs +++ b/server/src/services/install_events/repo.rs @@ -116,8 +116,8 @@ impl InstallEventsRepository for InstallEventsRepo { }; let event = InstallEvent { - id: Some(ObjectId::new()), - tenant_id: *tenant_id, + id: Some(crate::core::public_id::InstallEventId::new()), + tenant_id: crate::core::public_id::TenantId::from_object_id(*tenant_id), install_id: install_id.to_string(), event_type, timestamp: DateTime::now(), @@ -151,8 +151,8 @@ impl InstallEventsRepository for InstallEventsRepo { // Always write install.identified. let identified = InstallEvent { - id: Some(ObjectId::new()), - tenant_id: *tenant_id, + id: Some(crate::core::public_id::InstallEventId::new()), + tenant_id: crate::core::public_id::TenantId::from_object_id(*tenant_id), install_id: install_id.to_string(), event_type: InstallEventType::Identified, timestamp: now, @@ -186,8 +186,8 @@ impl InstallEventsRepository for InstallEventsRepo { }; let event = InstallEvent { - id: Some(ObjectId::new()), - tenant_id: *tenant_id, + id: Some(crate::core::public_id::InstallEventId::new()), + tenant_id: crate::core::public_id::TenantId::from_object_id(*tenant_id), install_id: install_id.to_string(), event_type: classification, timestamp: now, diff --git a/server/src/services/links/models.rs b/server/src/services/links/models.rs index 8705bf1..9b82049 100644 --- a/server/src/services/links/models.rs +++ b/server/src/services/links/models.rs @@ -1,8 +1,9 @@ -use mongodb::bson::{oid::ObjectId, DateTime, Document}; +use mongodb::bson::{DateTime, Document}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use utoipa::{IntoParams, ToSchema}; +use crate::core::public_id::{AffiliateId, TenantId}; use crate::core::threat_feed::ThreatFeed; use crate::services::affiliates::repo::AffiliatesRepository; use crate::services::app_users::repo::AppUsersRepository; @@ -84,9 +85,9 @@ pub struct SocialPreview { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Link { #[serde(rename = "_id")] - pub id: ObjectId, - /// Tenant who owns this link (API key ObjectId). - pub tenant_id: ObjectId, + pub id: crate::core::public_id::LinkInternalId, + /// Tenant who owns this link. + pub tenant_id: crate::core::public_id::TenantId, /// Short alphanumeric ID used in URLs (e.g. "ABCD1234"). pub link_id: String, /// iOS deep link URI (e.g. "myapp://product/123"). @@ -111,7 +112,7 @@ pub struct Link { /// Stamped automatically when minted by an affiliate-scoped credential; /// can also be set explicitly by an unscoped (Full) caller. #[serde(default, skip_serializing_if = "Option::is_none")] - pub affiliate_id: Option, + pub affiliate_id: Option, pub created_at: DateTime, /// Link safety status. #[serde(default)] @@ -133,7 +134,7 @@ pub struct Link { /// The `meta` subdocument is the metaField for time series bucketing. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClickMeta { - pub tenant_id: ObjectId, + pub tenant_id: TenantId, pub link_id: String, /// Retention bucket frozen at insert time. One of: "30d", "1y", "3y", /// "5y". Four partial TTL indexes on the time field + this value drop @@ -180,7 +181,7 @@ pub struct AttributionEvent { /// time-series only supports updates on meta-field paths). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttributionEventMeta { - pub tenant_id: ObjectId, + pub tenant_id: TenantId, pub install_id: String, /// Retention tier marker — stamped at insert from the tenant's plan, /// used by `ensure_retention_ttl_indexes`. Stays with the event for @@ -279,7 +280,7 @@ pub struct CreditedLinks { /// Parameters for creating a new link (passed to repository). pub struct CreateLinkInput { - pub tenant_id: ObjectId, + pub tenant_id: TenantId, pub link_id: String, pub ios_deep_link: Option, pub android_deep_link: Option, @@ -287,7 +288,7 @@ pub struct CreateLinkInput { pub ios_store_url: Option, pub android_store_url: Option, pub metadata: Option, - pub affiliate_id: Option, + pub affiliate_id: Option, pub expires_at: Option, pub agent_context: Option, pub social_preview: Option, @@ -304,7 +305,7 @@ pub struct CreateLinkInput { /// .metadata(metadata_doc) /// ``` impl CreateLinkInput { - pub fn new(tenant_id: ObjectId, link_id: String) -> Self { + pub fn new(tenant_id: TenantId, link_id: String) -> Self { Self { tenant_id, link_id, @@ -351,7 +352,7 @@ impl CreateLinkInput { self } - pub fn affiliate_id(mut self, v: Option) -> Self { + pub fn affiliate_id(mut self, v: Option) -> Self { self.affiliate_id = v; self } @@ -409,9 +410,7 @@ pub struct CreateLinkRequest { /// callers — server pins to the credential's affiliate. Mismatched values /// from a scoped caller return `affiliate_scope_mismatch`. #[serde(default)] - #[schema(value_type = String, example = "665a1b2c3d4e5f6a7b8c9d0e")] - #[cfg_attr(feature = "mcp", schemars(with = "Option"))] - pub affiliate_id: Option, + pub affiliate_id: Option, /// Structured context for AI agents. When set, agents resolving this link receive action, CTA, and description metadata alongside the destinations. #[serde(default)] pub agent_context: Option, @@ -480,24 +479,6 @@ where Option::deserialize(deserializer).map(Some) } -/// Serializes `Option` as a plain hex string (`"665a..."`) or -/// `null`, matching what the schemars / utoipa hints already declare. The -/// default bson `Serialize` impl emits extended JSON (`{"$oid": "..."}`) -/// which clients validating against the declared schema reject — most -/// visibly the MCP `Json` wrapper, which strictly validates outputs. -fn serialize_opt_object_id_as_hex( - value: &Option, - serializer: S, -) -> Result -where - S: serde::Serializer, -{ - match value { - Some(oid) => serializer.serialize_str(&oid.to_hex()), - None => serializer.serialize_none(), - } -} - #[derive(Debug, Serialize, ToSchema)] #[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))] pub struct LinkDetail { @@ -531,21 +512,8 @@ pub struct LinkDetail { #[schema(example = "2025-06-15T10:30:00Z")] pub created_at: String, /// Affiliate this link is attributed to. None for unattributed links. - /// - /// Serialized as a hex string so the output matches the schema hint - /// (`Option`) we already advertise to schemars and utoipa. The - /// default bson ObjectId `Serialize` impl emits `{"$oid": "..."}`, - /// which fails MCP `Json` schema validation and confuses REST - /// clients reading the OpenAPI spec. Hotfix only — see the public-ID - /// migration tracking issue for the proper fix that stops exposing - /// raw ObjectIds entirely. - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "serialize_opt_object_id_as_hex" - )] - #[schema(value_type = Option, example = "665a1b2c3d4e5f6a7b8c9d0e")] - #[cfg_attr(feature = "mcp", schemars(with = "Option"))] - pub affiliate_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub affiliate_id: Option, /// Structured context for AI agents resolving this link. #[serde(skip_serializing_if = "Option::is_none")] pub agent_context: Option, @@ -589,9 +557,7 @@ pub struct BulkLinkTemplate { /// Affiliate this whole batch should be attributed to. Optional for full-scope /// callers; ignored / overridden for affiliate-scoped callers. #[serde(default)] - #[schema(value_type = Option, example = "665a1b2c3d4e5f6a7b8c9d0e")] - #[cfg_attr(feature = "mcp", schemars(with = "Option"))] - pub affiliate_id: Option, + pub affiliate_id: Option, /// Structured context for AI agents applied to every link in the batch. #[serde(default)] pub agent_context: Option, @@ -668,7 +634,7 @@ pub struct ListLinksResponse { /// The current page of links, most recent first. pub links: Vec, /// Cursor for the next page. Null if no more results. - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + #[schema(example = "lnk_665a1b2c3d4e5f6a7b8c9d0e")] pub next_cursor: Option, } diff --git a/server/src/services/links/repo.rs b/server/src/services/links/repo.rs index 876ea1a..e9ee20a 100644 --- a/server/src/services/links/repo.rs +++ b/server/src/services/links/repo.rs @@ -313,7 +313,7 @@ impl LinksRepository for LinksRepo { .insert_one(&link) .await .map_err(|e| e.to_string())?; - invalidate_link_cache(&link.tenant_id, &link.link_id).await; + invalidate_link_cache(link.tenant_id.as_object_id(), &link.link_id).await; Ok(link) } @@ -347,7 +347,7 @@ impl LinksRepository for LinksRepo { .await .map_err(|e| BulkInsertError::Internal(e.to_string()))?; for d in &docs { - invalidate_link_cache(&d.tenant_id, &d.link_id).await; + invalidate_link_cache(d.tenant_id.as_object_id(), &d.link_id).await; } Ok(docs) } @@ -467,7 +467,7 @@ impl LinksRepository for LinksRepo { ) -> Result<(), String> { let event = ClickEvent { meta: ClickMeta { - tenant_id, + tenant_id: crate::core::public_id::TenantId::from_object_id(tenant_id), link_id: link_id.to_string(), retention_bucket, }, @@ -495,7 +495,7 @@ impl LinksRepository for LinksRepo { let event = AttributionEvent { timestamp: DateTime::now(), meta: AttributionEventMeta { - tenant_id, + tenant_id: crate::core::public_id::TenantId::from_object_id(tenant_id), install_id: install_id.to_string(), retention_bucket, user_id: user_id.map(|s| s.to_string()), @@ -716,7 +716,7 @@ impl LinksRepository for LinksRepo { fn build_link(input: CreateLinkInput) -> Link { Link { - id: ObjectId::new(), + id: crate::core::public_id::LinkInternalId::new(), tenant_id: input.tenant_id, link_id: input.link_id, ios_deep_link: input.ios_deep_link, diff --git a/server/src/services/links/service.rs b/server/src/services/links/service.rs index 73619ed..8c06351 100644 --- a/server/src/services/links/service.rs +++ b/server/src/services/links/service.rs @@ -1,4 +1,3 @@ -use mongodb::bson::oid::ObjectId; use mongodb::bson::DateTime; use rift_macros::requires; use std::sync::Arc; @@ -78,7 +77,7 @@ impl LinksService { /// of touching the repos directly. pub async fn identify_install( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, install_id: &str, user_id: &str, ) -> Result { @@ -88,13 +87,15 @@ impl LinksService { return Ok(IdentifyOutcome::AlreadyPresent); }; + let tenant_oid = tenant_id.as_object_id(); + // 1. Rebind guard. If the install is already bound to a different // user, refuse — option B from the cutover discussion. The // SDK's expected behavior is one install ↔ one user; rebinding // silently would let a logged-out + re-logged-in flow on a // shared device leak attribution across users. if let Some(existing) = app_users - .find_user_id_for_install(tenant_id, install_id) + .find_user_id_for_install(tenant_oid, install_id) .await .map_err(LinkError::Internal)? { @@ -109,7 +110,7 @@ impl LinksService { // the current one — this is what feeds the reinstall vs // new_device classification in step 4. let prior_install_ids: Vec = - match app_users.find_by_user_id(tenant_id, user_id).await { + match app_users.find_by_user_id(tenant_oid, user_id).await { Ok(Some(existing)) => existing.install_ids, Ok(None) => Vec::new(), Err(e) => { @@ -123,7 +124,7 @@ impl LinksService { }; let upsert = app_users - .upsert_with_install(tenant_id, user_id, install_id) + .upsert_with_install(tenant_oid, user_id, install_id) .await .map_err(LinkError::Internal)?; @@ -139,7 +140,7 @@ impl LinksService { // doesn't fail the identify. match self .links_repo - .backfill_user_id_on_attribution_events(tenant_id, install_id, user_id) + .backfill_user_id_on_attribution_events(tenant_oid, install_id, user_id) .await { Ok(n) if n > 0 => { @@ -166,14 +167,14 @@ impl LinksService { // reinstall vs new_device. if let Some(install_events) = &self.install_events_repo { let current_device_model = install_events - .get_device_model(tenant_id, install_id) + .get_device_model(tenant_oid, install_id) .await .ok() .flatten(); let mut prior_device_models = Vec::with_capacity(prior_install_ids.len()); for prior_id in &prior_install_ids { - if let Ok(Some(model)) = install_events.get_device_model(tenant_id, prior_id).await + if let Ok(Some(model)) = install_events.get_device_model(tenant_oid, prior_id).await { prior_device_models.push(model); } @@ -181,7 +182,7 @@ impl LinksService { if let Err(e) = install_events .record_identify_lifecycle( - tenant_id, + tenant_oid, install_id, user_id, &prior_install_ids, @@ -206,7 +207,7 @@ impl LinksService { // the webhook still fires with both fields absent. let credited_ids = self .links_repo - .credited_links_for_user(tenant_id, user_id, mongodb::bson::DateTime::now()) + .credited_links_for_user(tenant_oid, user_id, mongodb::bson::DateTime::now()) .await .unwrap_or_else(|e| { tracing::warn!( @@ -239,7 +240,7 @@ impl LinksService { /// path to `LinksRepository::record_click`. pub async fn record_click( &self, - tenant_id: ObjectId, + tenant_id: TenantId, link_id: &str, user_agent: Option, referer: Option, @@ -259,7 +260,7 @@ impl LinksService { if let Err(e) = self .links_repo .record_click( - tenant_id, + tenant_id.to_object_id(), link_id, user_agent, referer, @@ -282,7 +283,7 @@ impl LinksService { /// re-querying. pub async fn record_attribute_event( &self, - tenant_id: ObjectId, + tenant_id: TenantId, link_id: &str, install_id: &str, app_version: &str, @@ -299,13 +300,15 @@ impl LinksService { None => "30d".to_string(), }; + let tenant_oid = tenant_id.to_object_id(); + // Resolve user_id at write time so the row doesn't need to be // backfilled later for already-identified installs. Best-effort — // a lookup failure logs and falls back to None (the next identify // will backfill). let user_id = match &self.app_users_repo { Some(app_users) => app_users - .find_user_id_for_install(&tenant_id, install_id) + .find_user_id_for_install(&tenant_oid, install_id) .await .unwrap_or_else(|e| { tracing::warn!(error = %e, install_id, "app_users user_id lookup failed"); @@ -316,7 +319,7 @@ impl LinksService { self.links_repo .record_attribute_event( - tenant_id, + tenant_oid, link_id, install_id, app_version, @@ -334,7 +337,7 @@ impl LinksService { ..InstallContext::default() }); if let Err(e) = install_events - .record_attribute_lifecycle(&tenant_id, install_id, &ctx) + .record_attribute_lifecycle(&tenant_oid, install_id, &ctx) .await { tracing::warn!( @@ -355,7 +358,8 @@ impl LinksService { ctx: &AuthContext, req: CreateLinkRequest, ) -> Result { - let tenant_id = ctx.tenant_id.to_object_id(); + let tenant_id = ctx.tenant_id; + let tenant_oid = tenant_id.to_object_id(); // Quota enforcement lives here (service layer) so MCP tool invocations // and HTTP route handlers both hit the same choke point. CLAUDE.md // codifies this rule — see "Quota enforcement" section there. @@ -388,7 +392,7 @@ impl LinksService { if self .links_repo - .find_link_by_tenant_and_id(&tenant_id, custom) + .find_link_by_tenant_and_id(&tenant_oid, custom) .await .ok() .flatten() @@ -479,7 +483,8 @@ impl LinksService { ctx: &AuthContext, req: BulkCreateLinksRequest, ) -> Result { - let tenant_id = ctx.tenant_id.to_object_id(); + let tenant_id = ctx.tenant_id; + let tenant_oid = tenant_id.to_object_id(); // 1. Mode — exactly one of custom_ids / count. let mode_ids = req.custom_ids.as_deref(); let mode_count = req.count; @@ -587,7 +592,7 @@ impl LinksService { } if self .links_repo - .find_link_by_tenant_and_id(&tenant_id, id) + .find_link_by_tenant_and_id(&tenant_oid, id) .await .map_err(LinkError::Internal)? .is_some() @@ -609,7 +614,7 @@ impl LinksService { // 7. Quota gate for the whole batch. if let Some(q) = &self.quota { - q.check_n(&tenant_id, Resource::CreateLink, n as u64) + q.check_n(&ctx.tenant_id, Resource::CreateLink, n as u64) .await?; } @@ -691,7 +696,7 @@ impl LinksService { // links. Return NotFound (not Forbidden) so the existence of links // belonging to other affiliates isn't disclosed. if let ResourceScope::Affiliate { affiliate_id } = &ctx.resource_scope { - if link.affiliate_id != Some(affiliate_id.to_object_id()) { + if link.affiliate_id != Some(*affiliate_id) { return Err(LinkError::NotFound); } } @@ -707,14 +712,17 @@ impl LinksService { limit: Option, cursor: Option, ) -> Result { - let tenant_id = ctx.tenant_id.as_object_id(); let limit = limit.unwrap_or(50).clamp(1, 100); - let cursor_id = cursor.and_then(|c| ObjectId::parse_str(&c).ok()); + let cursor_id = cursor.and_then(|c| { + crate::core::public_id::LinkInternalId::parse(&c) + .ok() + .map(|id| id.to_object_id()) + }); // Fetch one extra to determine if there's a next page. let links = self .links_repo - .list_links_by_tenant(tenant_id, limit + 1, cursor_id) + .list_links_by_tenant(ctx.tenant_id.as_object_id(), limit + 1, cursor_id) .await .map_err(|e| { tracing::error!("Failed to list links: {e}"); @@ -725,13 +733,13 @@ impl LinksService { let page: Vec<&Link> = links.iter().take(limit as usize).collect(); let next_cursor = if has_more { - page.last().map(|l| l.id.to_hex()) + page.last().map(|l| l.id.to_string()) } else { None }; let primary_domain = - resolve_verified_primary_domain(self.domains_repo.as_deref(), tenant_id).await; + resolve_verified_primary_domain(self.domains_repo.as_deref(), &ctx.tenant_id).await; let details: Vec = page .iter() .map(|l| self.link_to_detail_with_domain(l, primary_domain.as_deref())) @@ -751,7 +759,6 @@ impl LinksService { link_id: &str, req: UpdateLinkRequest, ) -> Result { - let tenant_id = ctx.tenant_id.as_object_id(); // Flatten Option> to Option<&str> for validation. let ios_dl = req.ios_deep_link.as_ref().and_then(|v| v.as_deref()); let android_dl = req.android_deep_link.as_ref().and_then(|v| v.as_deref()); @@ -836,7 +843,7 @@ impl LinksService { let updated = self .links_repo - .update_link(tenant_id, link_id, update, unset) + .update_link(ctx.tenant_id.as_object_id(), link_id, update, unset) .await .map_err(|e| { tracing::error!("Failed to update link: {e}"); @@ -851,7 +858,7 @@ impl LinksService { // the caller's update was already authorized at the macro layer. let link = self .links_repo - .find_link_by_tenant_and_id(tenant_id, link_id) + .find_link_by_tenant_and_id(ctx.tenant_id.as_object_id(), link_id) .await .map_err(LinkError::Internal)? .ok_or(LinkError::NotFound)?; @@ -863,13 +870,13 @@ impl LinksService { /// No click recording, no landing page — the alternate domain is a Universal Link trampoline. pub async fn resolve_alternate( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, link_id: &str, user_agent: &str, ) -> Result { let link = self .links_repo - .find_link_by_tenant_and_id(tenant_id, link_id) + .find_link_by_tenant_and_id(tenant_id.as_object_id(), link_id) .await .map_err(LinkError::Internal)? .ok_or(LinkError::NotFound)?; @@ -934,11 +941,11 @@ impl LinksService { } } - async fn tenant_has_verified_domain(&self, tenant_id: &ObjectId) -> bool { + async fn tenant_has_verified_domain(&self, tenant_id: &TenantId) -> bool { let Some(ref repo) = self.domains_repo else { return false; }; - repo.list_by_tenant(tenant_id) + repo.list_by_tenant(tenant_id.as_object_id()) .await .ok() .map(|domains| domains.iter().any(|d| d.verified)) @@ -950,19 +957,15 @@ impl LinksService { /// `create_link` for the full matrix. async fn resolve_affiliate_id( &self, - tenant_id: &ObjectId, + tenant_id: &TenantId, resource_scope: &ResourceScope, - requested: Option, - ) -> Result, LinkError> { + requested: Option, + ) -> Result, LinkError> { match (resource_scope, requested) { // Affiliate-scoped credential — server pins to scope; reject mismatch. - (ResourceScope::Affiliate { affiliate_id }, None) => { - Ok(Some(affiliate_id.to_object_id())) - } - (ResourceScope::Affiliate { affiliate_id }, Some(req)) - if req == affiliate_id.to_object_id() => - { - Ok(Some(affiliate_id.to_object_id())) + (ResourceScope::Affiliate { affiliate_id }, None) => Ok(Some(*affiliate_id)), + (ResourceScope::Affiliate { affiliate_id }, Some(req)) if req == *affiliate_id => { + Ok(Some(*affiliate_id)) } (ResourceScope::Affiliate { .. }, Some(_)) => Err(LinkError::AffiliateScopeMismatch), @@ -973,13 +976,10 @@ impl LinksService { .affiliates_repo .as_ref() .ok_or(LinkError::AffiliateNotFound)?; - repo.get_by_id( - &TenantId::from_object_id(*tenant_id), - &AffiliateId::from_object_id(req), - ) - .await - .map_err(LinkError::Internal)? - .ok_or(LinkError::AffiliateNotFound)?; + repo.get_by_id(tenant_id, &req) + .await + .map_err(LinkError::Internal)? + .ok_or(LinkError::AffiliateNotFound)?; Ok(Some(req)) } @@ -987,7 +987,7 @@ impl LinksService { } } - pub async fn canonical_url(&self, tenant_id: &ObjectId, link_id: &str) -> String { + pub async fn canonical_url(&self, tenant_id: &TenantId, link_id: &str) -> String { let domain = resolve_verified_primary_domain(self.domains_repo.as_deref(), tenant_id).await; build_canonical_link_url(&self.public_url, link_id, domain.as_deref()) } @@ -1003,15 +1003,15 @@ impl LinksService { /// dispatch over a stale cache miss. pub async fn enrich_credited_with_metadata( links_repo: &dyn LinksRepository, - tenant_id: &ObjectId, + tenant_id: &TenantId, mut credited: CreditedLinks, ) -> CreditedLinks { async fn fetch_metadata( repo: &dyn LinksRepository, - tenant_id: &ObjectId, + tenant_id: &TenantId, link_id: &str, ) -> Option { - repo.find_link_by_tenant_and_id(tenant_id, link_id) + repo.find_link_by_tenant_and_id(tenant_id.as_object_id(), link_id) .await .unwrap_or_else(|e| { tracing::warn!(error = %e, link_id, "credited link metadata lookup failed"); @@ -1052,10 +1052,10 @@ pub fn build_canonical_link_url( pub async fn resolve_verified_primary_domain( domains_repo: Option<&dyn DomainsRepository>, - tenant_id: &ObjectId, + tenant_id: &TenantId, ) -> Option { let repo = domains_repo?; - repo.list_by_tenant(tenant_id) + repo.list_by_tenant(tenant_id.as_object_id()) .await .ok()? .into_iter() diff --git a/server/src/services/links/service_tests.rs b/server/src/services/links/service_tests.rs index 21008f0..a164780 100644 --- a/server/src/services/links/service_tests.rs +++ b/server/src/services/links/service_tests.rs @@ -15,7 +15,7 @@ use std::sync::Mutex; fn ctx(tenant_id: ObjectId) -> AuthContext { AuthContext::for_secret_key( crate::core::public_id::TenantId::from_object_id(tenant_id), - ObjectId::new(), + crate::core::public_id::SecretKeyId::new(), Some(&KeyScope::Full), ) } @@ -57,8 +57,8 @@ impl MockLinksRepo { fn make_link(tenant_id: ObjectId, link_id: &str) -> Link { Link { - id: ObjectId::new(), - tenant_id, + id: crate::core::public_id::LinkInternalId::new(), + tenant_id: crate::core::public_id::TenantId::from_object_id(tenant_id), link_id: link_id.to_string(), ios_deep_link: None, android_deep_link: None, @@ -84,7 +84,7 @@ impl LinksRepository for MockLinksRepo { return Err("E11000 duplicate key".to_string()); } let link = Link { - id: ObjectId::new(), + id: crate::core::public_id::LinkInternalId::new(), tenant_id: input.tenant_id, link_id: input.link_id, ios_deep_link: input.ios_deep_link, @@ -139,7 +139,7 @@ impl LinksRepository for MockLinksRepo { let new_links: Vec = inputs .into_iter() .map(|input| Link { - id: ObjectId::new(), + id: crate::core::public_id::LinkInternalId::new(), tenant_id: input.tenant_id, link_id: input.link_id, ios_deep_link: input.ios_deep_link, @@ -174,7 +174,7 @@ impl LinksRepository for MockLinksRepo { let links = self.links.lock().unwrap(); Ok(links .iter() - .find(|l| l.tenant_id == *tenant_id && l.link_id == link_id) + .find(|l| l.tenant_id.to_object_id() == *tenant_id && l.link_id == link_id) .cloned()) } @@ -188,7 +188,7 @@ impl LinksRepository for MockLinksRepo { let mut links = self.links.lock().unwrap(); let Some(link) = links .iter_mut() - .find(|l| l.tenant_id == *tenant_id && l.link_id == link_id) + .find(|l| l.tenant_id.to_object_id() == *tenant_id && l.link_id == link_id) else { return Ok(false); }; @@ -222,13 +222,16 @@ impl LinksRepository for MockLinksRepo { async fn delete_link(&self, tenant_id: &ObjectId, link_id: &str) -> Result { let mut links = self.links.lock().unwrap(); let len_before = links.len(); - links.retain(|l| !(l.tenant_id == *tenant_id && l.link_id == link_id)); + links.retain(|l| !(l.tenant_id.to_object_id() == *tenant_id && l.link_id == link_id)); Ok(links.len() < len_before) } async fn count_links_by_tenant(&self, tenant_id: &ObjectId) -> Result { let links = self.links.lock().unwrap(); - Ok(links.iter().filter(|l| l.tenant_id == *tenant_id).count() as u64) + Ok(links + .iter() + .filter(|l| l.tenant_id.to_object_id() == *tenant_id) + .count() as u64) } async fn list_links_by_tenant( @@ -240,7 +243,7 @@ impl LinksRepository for MockLinksRepo { let links = self.links.lock().unwrap(); Ok(links .iter() - .filter(|l| l.tenant_id == *tenant_id) + .filter(|l| l.tenant_id.to_object_id() == *tenant_id) .take(limit as usize) .cloned() .collect()) @@ -341,8 +344,8 @@ impl DomainsRepository for MockDomainsRepo { ) -> Result, String> { if self.has_verified { Ok(vec![crate::services::domains::models::Domain { - id: ObjectId::new(), - tenant_id: ObjectId::new(), + id: crate::core::public_id::DomainId::new(), + tenant_id: crate::core::public_id::TenantId::new(), domain: "example.com".to_string(), verified: true, verification_token: "token".to_string(), diff --git a/server/src/services/webhooks/dispatcher.rs b/server/src/services/webhooks/dispatcher.rs index 8c5b8b3..097f2fd 100644 --- a/server/src/services/webhooks/dispatcher.rs +++ b/server/src/services/webhooks/dispatcher.rs @@ -40,8 +40,8 @@ impl RiftWebhookDispatcher { let http = self.http.clone(); tokio::spawn(async move { - let tenant_oid = match mongodb::bson::oid::ObjectId::parse_str(&tenant_id) { - Ok(oid) => oid, + let tenant_oid = match crate::core::public_id::TenantId::parse(&tenant_id) { + Ok(id) => id, Err(_) => return, }; @@ -166,15 +166,16 @@ pub(crate) fn compute_hmac(secret: &str, body: &str) -> String { #[cached::proc_macro::cached( ty = "cached::TimedCache<(String, String), Vec>", create = "{ cached::TimedCache::with_lifespan(60) }", - convert = r#"{ (tenant_oid.to_hex(), format!("{:?}", event_type)) }"#, + convert = r#"{ (tenant_oid.as_hex(), format!("{:?}", event_type)) }"#, result = true )] async fn cached_find_active_for_event( repo: Arc, - tenant_oid: mongodb::bson::oid::ObjectId, + tenant_oid: crate::core::public_id::TenantId, event_type: WebhookEventType, ) -> Result, String> { - repo.find_active_for_event(&tenant_oid, &event_type).await + repo.find_active_for_event(&tenant_oid.to_object_id(), &event_type) + .await } async fn deliver_with_retry(http: &reqwest::Client, url: &str, body: &str, signature: &str) { diff --git a/server/src/services/webhooks/models.rs b/server/src/services/webhooks/models.rs index f3c49aa..163fa8d 100644 --- a/server/src/services/webhooks/models.rs +++ b/server/src/services/webhooks/models.rs @@ -1,7 +1,9 @@ -use mongodb::bson::{oid::ObjectId, DateTime}; +use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::core::public_id::{TenantId, WebhookId}; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)] #[serde(rename_all = "snake_case")] pub enum WebhookEventType { @@ -26,8 +28,8 @@ pub enum WebhookEventType { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Webhook { #[serde(rename = "_id")] - pub id: ObjectId, - pub tenant_id: ObjectId, + pub id: WebhookId, + pub tenant_id: TenantId, pub url: String, pub secret: String, pub events: Vec, @@ -48,8 +50,7 @@ pub struct CreateWebhookRequest { #[derive(Debug, Serialize, ToSchema)] pub struct CreateWebhookResponse { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: WebhookId, #[schema(example = "https://api.tablefour.com/webhooks/relay")] pub url: String, pub events: Vec, @@ -62,8 +63,7 @@ pub struct CreateWebhookResponse { #[derive(Debug, Serialize, ToSchema)] pub struct WebhookDetail { - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] - pub id: String, + pub id: WebhookId, #[schema(example = "https://api.tablefour.com/webhooks/relay")] pub url: String, pub events: Vec, diff --git a/server/src/services/webhooks/service.rs b/server/src/services/webhooks/service.rs index a1fe834..7670622 100644 --- a/server/src/services/webhooks/service.rs +++ b/server/src/services/webhooks/service.rs @@ -3,7 +3,6 @@ //! Same rule as DomainsService: the service layer is the one place both //! `api/` and (future) `mcp/` consumers call, so quota lives here. -use mongodb::bson::oid::ObjectId; use rift_macros::requires; use std::sync::Arc; @@ -29,20 +28,19 @@ impl WebhooksService { pub async fn create_webhook( &self, ctx: &AuthContext, - id: ObjectId, + id: crate::core::public_id::WebhookId, url: String, secret: String, events: Vec, created_at: mongodb::bson::DateTime, ) -> Result { if let Some(q) = &self.quota { - q.check(ctx.tenant_id.as_object_id(), Resource::CreateWebhook) - .await?; + q.check(&ctx.tenant_id, Resource::CreateWebhook).await?; } let webhook = Webhook { id, - tenant_id: ctx.tenant_id.to_object_id(), + tenant_id: ctx.tenant_id, url, secret, events, diff --git a/server/tests/api/affiliate_credentials.rs b/server/tests/api/affiliate_credentials.rs index e0357de..f245f9a 100644 --- a/server/tests/api/affiliate_credentials.rs +++ b/server/tests/api/affiliate_credentials.rs @@ -43,7 +43,9 @@ async fn mint_credential_with_scoped_caller_is_forbidden() { let app = common::spawn_app().await; let (key, tenant_id) = common::seed_api_key(&app).await; let aff = create_affiliate(&app, &key).await; - let aff_oid = mongodb::bson::oid::ObjectId::parse_str(&aff).unwrap(); + let aff_oid = rift::core::public_id::AffiliateId::parse(&aff) + .unwrap() + .to_object_id(); let partner_key = common::seed_affiliate_scoped_key( &app, @@ -263,7 +265,9 @@ async fn scoped_key_creates_link_pinned_to_affiliate() { let app = common::spawn_app().await; let (key, _) = common::seed_api_key(&app).await; let aff = create_affiliate(&app, &key).await; - let aff_oid = mongodb::bson::oid::ObjectId::parse_str(&aff).unwrap(); + let aff_oid = rift::core::public_id::AffiliateId::parse(&aff) + .unwrap() + .to_object_id(); let mint = app .client @@ -303,7 +307,10 @@ async fn scoped_key_creates_link_pinned_to_affiliate() { .find(|l| l.link_id == link_id) .cloned() .unwrap(); - assert_eq!(stored.affiliate_id, Some(aff_oid)); + assert_eq!( + stored.affiliate_id, + Some(rift::core::public_id::AffiliateId::from_object_id(aff_oid)) + ); } #[tokio::test] @@ -325,7 +332,7 @@ async fn scoped_key_with_mismatched_affiliate_id_400s() { .to_string(); // Some other (random) ObjectId. - let other = mongodb::bson::oid::ObjectId::new().to_hex(); + let other = rift::core::public_id::AffiliateId::new().to_string(); let resp = app .client @@ -348,7 +355,7 @@ async fn full_scope_with_unknown_affiliate_id_404s() { let app = common::spawn_app().await; let (key, _) = common::seed_api_key(&app).await; - let bogus = mongodb::bson::oid::ObjectId::new().to_hex(); + let bogus = rift::core::public_id::AffiliateId::new().to_string(); let resp = app .client @@ -371,7 +378,9 @@ async fn full_scope_with_known_affiliate_id_succeeds() { let app = common::spawn_app().await; let (key, _) = common::seed_api_key(&app).await; let aff = create_affiliate(&app, &key).await; - let aff_oid = mongodb::bson::oid::ObjectId::parse_str(&aff).unwrap(); + let aff_oid = rift::core::public_id::AffiliateId::parse(&aff) + .unwrap() + .to_object_id(); let resp = app .client @@ -397,7 +406,10 @@ async fn full_scope_with_known_affiliate_id_succeeds() { .find(|l| l.link_id == link_id) .cloned() .unwrap(); - assert_eq!(stored.affiliate_id, Some(aff_oid)); + assert_eq!( + stored.affiliate_id, + Some(rift::core::public_id::AffiliateId::from_object_id(aff_oid)) + ); } // ── Middleware allowlist ── diff --git a/server/tests/api/affiliates.rs b/server/tests/api/affiliates.rs index b1bfb5b..95dba9c 100644 --- a/server/tests/api/affiliates.rs +++ b/server/tests/api/affiliates.rs @@ -294,7 +294,9 @@ async fn scoped_key_cannot_create_affiliate() { .as_str() .unwrap() .to_string(); - let aff_oid = mongodb::bson::oid::ObjectId::parse_str(&id).unwrap(); + let aff_oid = rift::core::public_id::AffiliateId::parse(&id) + .unwrap() + .to_object_id(); // Seed a partner-scoped credential and try to create another affiliate. let partner_key = common::seed_affiliate_scoped_key( diff --git a/server/tests/api/webhooks.rs b/server/tests/api/webhooks.rs index 4da4614..e19a732 100644 --- a/server/tests/api/webhooks.rs +++ b/server/tests/api/webhooks.rs @@ -177,7 +177,7 @@ async fn delete_nonexistent_webhook_returns_404() { let app = common::spawn_app().await; let (key, _) = common::seed_api_key(&app).await; - let fake_id = mongodb::bson::oid::ObjectId::new().to_hex(); + let fake_id = rift::core::public_id::WebhookId::new().to_string(); let resp = app .client .delete(app.url(&format!("/v1/webhooks/{fake_id}"))) @@ -219,7 +219,10 @@ async fn click_dispatches_webhook() { let clicks = app.webhook_dispatcher.click_payloads.lock().unwrap(); assert_eq!(clicks.len(), 1); assert_eq!(clicks[0].link_id, "webhook-click"); - assert_eq!(clicks[0].tenant_id, tenant_id.to_hex()); + assert_eq!( + clicks[0].tenant_id, + rift::core::public_id::TenantId::from_object_id(tenant_id).to_string() + ); } #[tokio::test] @@ -261,7 +264,10 @@ async fn attribute_dispatches_webhook() { assert_eq!(attrs.len(), 1); assert_eq!(attrs[0].link_id, "webhook-attr"); assert_eq!(attrs[0].install_id, "install-123"); - assert_eq!(attrs[0].tenant_id, tenant_id.to_hex()); + assert_eq!( + attrs[0].tenant_id, + rift::core::public_id::TenantId::from_object_id(tenant_id).to_string() + ); } #[tokio::test] @@ -321,7 +327,10 @@ async fn identify_dispatches_webhook_with_link_metadata() { let events = app.webhook_dispatcher.identify_payloads.lock().unwrap(); assert_eq!(events.len(), 1, "expected exactly one identify event"); let evt = &events[0]; - assert_eq!(evt.tenant_id, tenant_id.to_hex()); + assert_eq!( + evt.tenant_id, + rift::core::public_id::TenantId::from_object_id(tenant_id).to_string() + ); assert_eq!(evt.user_id, "user-abc"); assert_eq!(evt.install_id, "install-id-7"); } diff --git a/server/tests/common/mocks/app_users.rs b/server/tests/common/mocks/app_users.rs index 3529432..30063a6 100644 --- a/server/tests/common/mocks/app_users.rs +++ b/server/tests/common/mocks/app_users.rs @@ -23,7 +23,7 @@ impl AppUsersRepository for MockAppUsersRepo { let mut rows = self.rows.lock().unwrap(); if let Some(row) = rows .iter_mut() - .find(|r| &r.tenant_id == tenant_id && r.user_id == user_id) + .find(|r| r.tenant_id.to_object_id() == *tenant_id && r.user_id == user_id) { if row.install_ids.iter().any(|i| i == install_id) { Ok(AppUserUpsert::AlreadyPresent) @@ -33,8 +33,8 @@ impl AppUsersRepository for MockAppUsersRepo { } } else { rows.push(AppUserDoc { - id: Some(ObjectId::new()), - tenant_id: *tenant_id, + id: Some(rift::core::public_id::AppUserId::new()), + tenant_id: rift::core::public_id::TenantId::from_object_id(*tenant_id), user_id: user_id.to_string(), install_ids: vec![install_id.to_string()], identified_at: mongodb::bson::DateTime::now(), @@ -60,7 +60,7 @@ impl AppUsersRepository for MockAppUsersRepo { let rows = self.rows.lock().unwrap(); Ok(rows .iter() - .find(|r| &r.tenant_id == tenant_id && r.user_id == user_id) + .find(|r| r.tenant_id.to_object_id() == *tenant_id && r.user_id == user_id) .cloned()) } @@ -72,7 +72,10 @@ impl AppUsersRepository for MockAppUsersRepo { let rows = self.rows.lock().unwrap(); Ok(rows .iter() - .find(|r| &r.tenant_id == tenant_id && r.install_ids.iter().any(|i| i == install_id)) + .find(|r| { + r.tenant_id.to_object_id() == *tenant_id + && r.install_ids.iter().any(|i| i == install_id) + }) .map(|r| r.user_id.clone())) } } diff --git a/server/tests/common/mocks/apps.rs b/server/tests/common/mocks/apps.rs index e30461f..7895daf 100644 --- a/server/tests/common/mocks/apps.rs +++ b/server/tests/common/mocks/apps.rs @@ -38,7 +38,7 @@ impl AppsRepository for MockAppsRepo { .lock() .unwrap() .iter() - .filter(|a| &a.tenant_id == tenant_id) + .filter(|a| a.tenant_id.to_object_id() == *tenant_id) .cloned() .collect()) } @@ -53,14 +53,16 @@ impl AppsRepository for MockAppsRepo { .lock() .unwrap() .iter() - .find(|a| &a.tenant_id == tenant_id && a.platform == platform) + .find(|a| a.tenant_id.to_object_id() == *tenant_id && a.platform == platform) .cloned()) } async fn delete_app(&self, tenant_id: &ObjectId, app_id: &ObjectId) -> Result { let mut apps = self.apps.lock().unwrap(); let len_before = apps.len(); - apps.retain(|a| !(&a.tenant_id == tenant_id && &a.id == app_id)); + apps.retain(|a| { + !(a.tenant_id.to_object_id() == *tenant_id && a.id.to_object_id() == *app_id) + }); Ok(apps.len() < len_before) } } diff --git a/server/tests/common/mocks/domains.rs b/server/tests/common/mocks/domains.rs index f18f6e0..f3676d4 100644 --- a/server/tests/common/mocks/domains.rs +++ b/server/tests/common/mocks/domains.rs @@ -24,8 +24,8 @@ impl DomainsRepository for MockDomainsRepo { return Err("E11000 duplicate key".to_string()); } let doc = Domain { - id: ObjectId::new(), - tenant_id, + id: rift::core::public_id::DomainId::new(), + tenant_id: rift::core::public_id::TenantId::from_object_id(tenant_id), domain, verified: false, verification_token, @@ -52,7 +52,7 @@ impl DomainsRepository for MockDomainsRepo { .lock() .unwrap() .iter() - .filter(|d| &d.tenant_id == tenant_id) + .filter(|d| d.tenant_id.to_object_id() == *tenant_id) .cloned() .collect()) } @@ -63,14 +63,14 @@ impl DomainsRepository for MockDomainsRepo { .lock() .unwrap() .iter() - .filter(|d| &d.tenant_id == tenant_id) + .filter(|d| d.tenant_id.to_object_id() == *tenant_id) .count() as u64) } async fn delete_domain(&self, tenant_id: &ObjectId, domain: &str) -> Result { let mut domains = self.domains.lock().unwrap(); let len_before = domains.len(); - domains.retain(|d| !(&d.tenant_id == tenant_id && d.domain == domain)); + domains.retain(|d| !(d.tenant_id.to_object_id() == *tenant_id && d.domain == domain)); Ok(domains.len() < len_before) } @@ -91,7 +91,11 @@ impl DomainsRepository for MockDomainsRepo { .lock() .unwrap() .iter() - .find(|d| &d.tenant_id == tenant_id && d.role == DomainRole::Alternate && d.verified) + .find(|d| { + d.tenant_id.to_object_id() == *tenant_id + && d.role == DomainRole::Alternate + && d.verified + }) .cloned()) } } diff --git a/server/tests/common/mocks/links.rs b/server/tests/common/mocks/links.rs index b6ca088..875b025 100644 --- a/server/tests/common/mocks/links.rs +++ b/server/tests/common/mocks/links.rs @@ -23,7 +23,7 @@ impl LinksRepository for MockLinksRepo { return Err("E11000 duplicate key".to_string()); } let link = Link { - id: ObjectId::new(), + id: rift::core::public_id::LinkInternalId::new(), tenant_id: input.tenant_id, link_id: input.link_id, ios_deep_link: input.ios_deep_link, @@ -76,7 +76,7 @@ impl LinksRepository for MockLinksRepo { let new_links: Vec = inputs .into_iter() .map(|input| Link { - id: ObjectId::new(), + id: rift::core::public_id::LinkInternalId::new(), tenant_id: input.tenant_id, link_id: input.link_id, ios_deep_link: input.ios_deep_link, @@ -118,7 +118,7 @@ impl LinksRepository for MockLinksRepo { .lock() .unwrap() .iter() - .find(|l| &l.tenant_id == tenant_id && l.link_id == link_id) + .find(|l| l.tenant_id.to_object_id() == *tenant_id && l.link_id == link_id) .cloned()) } @@ -132,7 +132,7 @@ impl LinksRepository for MockLinksRepo { let mut links = self.links.lock().unwrap(); let Some(link) = links .iter_mut() - .find(|l| &l.tenant_id == tenant_id && l.link_id == link_id) + .find(|l| l.tenant_id.to_object_id() == *tenant_id && l.link_id == link_id) else { return Ok(false); }; @@ -175,13 +175,16 @@ impl LinksRepository for MockLinksRepo { async fn delete_link(&self, tenant_id: &ObjectId, link_id: &str) -> Result { let mut links = self.links.lock().unwrap(); let len_before = links.len(); - links.retain(|l| !(&l.tenant_id == tenant_id && l.link_id == link_id)); + links.retain(|l| !(l.tenant_id.to_object_id() == *tenant_id && l.link_id == link_id)); Ok(links.len() < len_before) } async fn count_links_by_tenant(&self, tenant_id: &ObjectId) -> Result { let links = self.links.lock().unwrap(); - Ok(links.iter().filter(|l| &l.tenant_id == tenant_id).count() as u64) + Ok(links + .iter() + .filter(|l| l.tenant_id.to_object_id() == *tenant_id) + .count() as u64) } async fn list_links_by_tenant( @@ -193,7 +196,10 @@ impl LinksRepository for MockLinksRepo { let links = self.links.lock().unwrap(); let mut filtered: Vec = links .iter() - .filter(|l| &l.tenant_id == tenant_id && cursor.is_none_or(|c| l.id < c)) + .filter(|l| { + l.tenant_id.to_object_id() == *tenant_id + && cursor.is_none_or(|c| l.id.to_object_id() < c) + }) .cloned() .collect(); // Sort by _id descending (ObjectIds are monotonically increasing). @@ -213,7 +219,7 @@ impl LinksRepository for MockLinksRepo { ) -> Result<(), String> { self.clicks.lock().unwrap().push(ClickEvent { meta: ClickMeta { - tenant_id, + tenant_id: rift::core::public_id::TenantId::from_object_id(tenant_id), link_id: link_id.to_string(), retention_bucket, }, diff --git a/server/tests/common/mocks/sdk_keys.rs b/server/tests/common/mocks/sdk_keys.rs index 5a455f9..8b081f8 100644 --- a/server/tests/common/mocks/sdk_keys.rs +++ b/server/tests/common/mocks/sdk_keys.rs @@ -33,7 +33,7 @@ impl SdkKeysRepository for MockSdkKeysRepo { .lock() .unwrap() .iter() - .filter(|k| &k.tenant_id == tenant_id && !k.revoked) + .filter(|k| k.tenant_id.to_object_id() == *tenant_id && !k.revoked) .cloned() .collect()) } @@ -42,7 +42,7 @@ impl SdkKeysRepository for MockSdkKeysRepo { let mut keys = self.keys.lock().unwrap(); if let Some(key) = keys .iter_mut() - .find(|k| &k.id == key_id && &k.tenant_id == tenant_id) + .find(|k| k.id.to_object_id() == *key_id && k.tenant_id.to_object_id() == *tenant_id) { key.revoked = true; Ok(true) diff --git a/server/tests/common/mocks/secret_keys.rs b/server/tests/common/mocks/secret_keys.rs index bc8ec04..ca10542 100644 --- a/server/tests/common/mocks/secret_keys.rs +++ b/server/tests/common/mocks/secret_keys.rs @@ -32,7 +32,7 @@ impl SecretKeysRepository for MockSecretKeysRepo { .lock() .unwrap() .iter() - .filter(|k| k.tenant_id == *tenant_id) + .filter(|k| k.tenant_id.to_object_id() == *tenant_id) .cloned() .collect()) } @@ -43,14 +43,16 @@ impl SecretKeysRepository for MockSecretKeysRepo { .lock() .unwrap() .iter() - .filter(|k| k.tenant_id == *tenant_id) + .filter(|k| k.tenant_id.to_object_id() == *tenant_id) .count() as i64) } async fn delete_key(&self, tenant_id: &ObjectId, key_id: &ObjectId) -> Result { let mut keys = self.keys.lock().unwrap(); let len = keys.len(); - keys.retain(|k| !(k.id == *key_id && k.tenant_id == *tenant_id)); + keys.retain(|k| { + !(k.id.to_object_id() == *key_id && k.tenant_id.to_object_id() == *tenant_id) + }); Ok(keys.len() < len) } @@ -65,10 +67,10 @@ impl SecretKeysRepository for MockSecretKeysRepo { .unwrap() .iter() .filter(|k| { - k.tenant_id == *tenant_id + k.tenant_id.to_object_id() == *tenant_id && matches!( &k.scope, - Some(KeyScope::Affiliate { affiliate_id: a }) if a == affiliate_id + Some(KeyScope::Affiliate { affiliate_id: a }) if a.to_object_id() == *affiliate_id ) }) .cloned() @@ -84,11 +86,11 @@ impl SecretKeysRepository for MockSecretKeysRepo { let mut keys = self.keys.lock().unwrap(); let len = keys.len(); keys.retain(|k| { - !(k.id == *key_id - && k.tenant_id == *tenant_id + !(k.id.to_object_id() == *key_id + && k.tenant_id.to_object_id() == *tenant_id && matches!( &k.scope, - Some(KeyScope::Affiliate { affiliate_id: a }) if a == affiliate_id + Some(KeyScope::Affiliate { affiliate_id: a }) if a.to_object_id() == *affiliate_id )) }); Ok(keys.len() < len) diff --git a/server/tests/common/mocks/tenants.rs b/server/tests/common/mocks/tenants.rs index ce1cc0c..cb5f99f 100644 --- a/server/tests/common/mocks/tenants.rs +++ b/server/tests/common/mocks/tenants.rs @@ -24,7 +24,7 @@ impl TenantsRepository for MockTenantsRepo { .lock() .unwrap() .iter() - .find(|t| t.id.as_ref() == Some(id)) + .find(|t| t.id.as_ref().map(|i| i.to_object_id()).as_ref() == Some(id)) .cloned()) } @@ -47,7 +47,10 @@ impl TenantsRepository for MockTenantsRepo { update: SubscriptionUpdate, ) -> Result { let mut guard = self.tenants.lock().unwrap(); - if let Some(t) = guard.iter_mut().find(|t| t.id.as_ref() == Some(tenant_id)) { + if let Some(t) = guard + .iter_mut() + .find(|t| t.id.as_ref().map(|i| i.to_object_id()).as_ref() == Some(tenant_id)) + { if let Some(x) = update.plan_tier { t.plan_tier = x; } @@ -77,7 +80,10 @@ impl TenantsRepository for MockTenantsRepo { async fn clear_subscription(&self, tenant_id: &ObjectId) -> Result { let mut guard = self.tenants.lock().unwrap(); - if let Some(t) = guard.iter_mut().find(|t| t.id.as_ref() == Some(tenant_id)) { + if let Some(t) = guard + .iter_mut() + .find(|t| t.id.as_ref().map(|i| i.to_object_id()).as_ref() == Some(tenant_id)) + { t.plan_tier = PlanTier::Free; t.billing_method = BillingMethod::Free; t.status = SubscriptionStatus::Canceled; diff --git a/server/tests/common/mocks/users.rs b/server/tests/common/mocks/users.rs index 9c8f49c..6d26db1 100644 --- a/server/tests/common/mocks/users.rs +++ b/server/tests/common/mocks/users.rs @@ -37,7 +37,7 @@ impl UsersRepository for MockUsersRepo { .lock() .unwrap() .iter() - .find(|u| u.tenant_id == *tenant_id && u.email == email) + .find(|u| u.tenant_id.to_object_id() == *tenant_id && u.email == email) .cloned()) } @@ -47,7 +47,7 @@ impl UsersRepository for MockUsersRepo { .lock() .unwrap() .iter() - .filter(|u| u.tenant_id == *tenant_id) + .filter(|u| u.tenant_id.to_object_id() == *tenant_id) .cloned() .collect()) } @@ -58,14 +58,17 @@ impl UsersRepository for MockUsersRepo { .lock() .unwrap() .iter() - .filter(|u| u.tenant_id == *tenant_id && u.verified) + .filter(|u| u.tenant_id.to_object_id() == *tenant_id && u.verified) .count() as i64) } async fn delete(&self, tenant_id: &ObjectId, user_id: &ObjectId) -> Result { let mut users = self.users.lock().unwrap(); let len = users.len(); - users.retain(|u| !(u.id.as_ref() == Some(user_id) && u.tenant_id == *tenant_id)); + users.retain(|u| { + !(u.id.as_ref().map(|i| i.to_object_id()).as_ref() == Some(user_id) + && u.tenant_id.to_object_id() == *tenant_id) + }); Ok(users.len() < len) } diff --git a/server/tests/common/mocks/webhooks.rs b/server/tests/common/mocks/webhooks.rs index 1b5d680..1a70f19 100644 --- a/server/tests/common/mocks/webhooks.rs +++ b/server/tests/common/mocks/webhooks.rs @@ -28,7 +28,7 @@ impl WebhooksRepository for MockWebhooksRepo { .lock() .unwrap() .iter() - .filter(|w| &w.tenant_id == tenant_id) + .filter(|w| w.tenant_id.to_object_id() == *tenant_id) .cloned() .collect()) } @@ -39,7 +39,7 @@ impl WebhooksRepository for MockWebhooksRepo { .lock() .unwrap() .iter() - .filter(|w| &w.tenant_id == tenant_id) + .filter(|w| w.tenant_id.to_object_id() == *tenant_id) .count() as u64) } @@ -50,7 +50,9 @@ impl WebhooksRepository for MockWebhooksRepo { ) -> Result { let mut webhooks = self.webhooks.lock().unwrap(); let len_before = webhooks.len(); - webhooks.retain(|w| !(&w.tenant_id == tenant_id && &w.id == webhook_id)); + webhooks.retain(|w| { + !(w.tenant_id.to_object_id() == *tenant_id && w.id.to_object_id() == *webhook_id) + }); Ok(webhooks.len() < len_before) } @@ -63,10 +65,9 @@ impl WebhooksRepository for MockWebhooksRepo { url: Option, ) -> Result { let mut webhooks = self.webhooks.lock().unwrap(); - match webhooks - .iter_mut() - .find(|w| &w.tenant_id == tenant_id && &w.id == webhook_id) - { + match webhooks.iter_mut().find(|w| { + w.tenant_id.to_object_id() == *tenant_id && w.id.to_object_id() == *webhook_id + }) { Some(w) => { if let Some(a) = active { w.active = a; @@ -93,7 +94,11 @@ impl WebhooksRepository for MockWebhooksRepo { .lock() .unwrap() .iter() - .filter(|w| &w.tenant_id == tenant_id && w.active && w.events.contains(event_type)) + .filter(|w| { + w.tenant_id.to_object_id() == *tenant_id + && w.active + && w.events.contains(event_type) + }) .cloned() .collect()) } diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs index 2b0cbdc..f3ed5c8 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -264,8 +264,8 @@ pub async fn seed_sdk_key(app: &TestApp, tenant_id: &ObjectId, domain: &str) -> let raw_key = format!("pk_live_test_{}", hex::encode(ObjectId::new().bytes())); let hash = hex::encode(sha2::Sha256::digest(raw_key.as_bytes())); let doc = rift::services::auth::publishable_keys::models::SdkKeyDoc { - id: ObjectId::new(), - tenant_id: *tenant_id, + id: rift::core::public_id::PublishableKeyId::new(), + tenant_id: rift::core::public_id::TenantId::from_object_id(*tenant_id), key_hash: hash, key_prefix: format!("{}...", &raw_key[..20]), domain: domain.to_string(), @@ -289,7 +289,7 @@ pub async fn seed_api_key_with(app: &TestApp, raw_key: &str) -> (String, ObjectI // Create tenant let tenant_doc = TenantDoc { - id: Some(tenant_id), + id: Some(rift::core::public_id::TenantId::from_object_id(tenant_id)), monthly_quota: 1000, ..TenantDoc::default() }; @@ -299,9 +299,9 @@ pub async fn seed_api_key_with(app: &TestApp, raw_key: &str) -> (String, ObjectI // post-migration production semantics (advertiser key, full tenant access). // Tests that need affiliate-scoped credentials build the doc inline. let key_doc = SecretKeyDoc { - id: ObjectId::new(), - tenant_id, - created_by: user_id, + id: rift::core::public_id::SecretKeyId::new(), + tenant_id: rift::core::public_id::TenantId::from_object_id(tenant_id), + created_by: rift::core::public_id::UserId::from_object_id(user_id), key_hash: hash, key_prefix: format!("{}...", &raw_key[..18]), created_at: mongodb::bson::DateTime::now(), @@ -322,13 +322,17 @@ pub async fn seed_affiliate_scoped_key( ) -> String { let hash = hex::encode(Sha256::digest(raw_key.as_bytes())); let key_doc = SecretKeyDoc { - id: ObjectId::new(), - tenant_id, - created_by: ObjectId::new(), + id: rift::core::public_id::SecretKeyId::new(), + tenant_id: rift::core::public_id::TenantId::from_object_id(tenant_id), + created_by: rift::core::public_id::UserId::new(), key_hash: hash, key_prefix: format!("{}...", &raw_key[..18]), created_at: mongodb::bson::DateTime::now(), - scope: Some(rift::services::auth::secret_keys::repo::KeyScope::Affiliate { affiliate_id }), + scope: Some( + rift::services::auth::secret_keys::repo::KeyScope::Affiliate { + affiliate_id: rift::core::public_id::AffiliateId::from_object_id(affiliate_id), + }, + ), }; app.secret_keys_repo.create_key(&key_doc).await.unwrap(); raw_key.to_string()