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 5d31ef7..bec5ba3 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) @@ -124,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 ea2b1e0..3426e75 100644 --- a/server/src/api/affiliates/routes.rs +++ b/server/src/api/affiliates/routes.rs @@ -1,12 +1,12 @@ 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; 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 +71,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 +82,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 +98,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 +111,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 +128,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 +139,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), } @@ -170,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), @@ -183,21 +174,25 @@ 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, oid, auth_key.0).await { + match svc + .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(), - affiliate_id: minted.affiliate_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, created_at: minted @@ -216,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), @@ -227,21 +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, 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(), }) @@ -257,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"), @@ -270,19 +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, 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), } @@ -292,7 +278,7 @@ pub async fn revoke_affiliate_credential( fn to_detail(a: &Affiliate) -> AffiliateDetail { AffiliateDetail { - id: a.id.to_hex(), + id: a.id, name: a.name.clone(), partner_key: a.partner_key.clone(), status: a.status, @@ -336,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 78cdc6e..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.0, + id: crate::core::public_id::AppId::new(), + tenant_id: tenant, platform: platform.clone(), bundle_id: req.bundle_id, team_id: req.team_id, @@ -147,7 +146,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() @@ -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.0, &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 be8ab4d..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,7 +63,7 @@ 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(tenant_id); req.extensions_mut().insert(AuthKeyId(key_id)); req.extensions_mut().insert(AuthContext::for_secret_key( tenant_id, @@ -214,9 +213,9 @@ 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(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( resolved.tenant_id, resolved.user_id, @@ -271,9 +270,9 @@ 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(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( resolved.tenant_id, resolved.user_id, @@ -325,7 +324,7 @@ pub async fn session_or_key_auth_gate( } } - req.extensions_mut().insert(TenantId(tenant_id)); + req.extensions_mut().insert(tenant_id); req.extensions_mut().insert(AuthKeyId(key_id)); req.extensions_mut().insert(AuthContext::for_secret_key( tenant_id, @@ -444,7 +443,7 @@ pub async fn sdk_auth_gate( .into_response(); } - req.extensions_mut().insert(TenantId(doc.tenant_id)); + req.extensions_mut().insert(doc.tenant_id); req.extensions_mut().insert(SdkDomain(doc.domain)); next.run(req).await @@ -495,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 27e0de9..1aff3a2 100644 --- a/server/src/api/auth/models.rs +++ b/server/src/api/auth/models.rs @@ -1,38 +1,20 @@ //! 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). - -use mongodb::bson::oid::ObjectId; +//! `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` and `SdkDomain` are still local newtypes — they aren't yet +//! migrated to typed `Id

` aliases (secret_keys migration pending). -/// 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::{AuthSessionId as SessionId, SecretKeyId, 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); - -/// 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)] -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 90c42a9..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.0 { + 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.0, + 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(), @@ -139,12 +138,12 @@ 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() .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.0, &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 c07b643..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.0) 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.to_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/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/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 f2be953..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; @@ -51,10 +50,13 @@ 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(), + id: source.id, name: source.name.clone(), source_type: source.source_type.clone(), webhook_url: webhook_url_for(&state, &source.url_token), @@ -111,7 +113,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 +128,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"); @@ -160,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 ( @@ -170,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.0, &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, @@ -213,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 ( @@ -223,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.0, &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, @@ -360,7 +355,7 @@ 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, parsed).await; Json(json!({ "accepted": result.accepted, @@ -383,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 fb3acfb..f9032c9 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.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 23f8b00..ec2e964 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(); @@ -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, @@ -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(); @@ -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.0, &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,7 +294,7 @@ 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, &req.install_id, &req.user_id, credited); Json(json!({ "success": true })).into_response() } Ok(IdentifyOutcome::AlreadyPresent) => { @@ -340,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, @@ -349,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/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/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 5502774..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, @@ -112,12 +112,12 @@ 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() .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.0, &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 ( @@ -256,15 +245,24 @@ 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(); - match webhooks.iter().find(|w| w.id == oid) { + let webhooks = repo + .list_by_tenant(&tenant.to_object_id()) + .await + .unwrap_or_default(); + 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 c7edf08..d50c040 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,14 @@ 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] = &[]; + /// 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 +703,148 @@ 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", + // 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 { + // 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 — 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") { + 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 +} + +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 +962,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..69bd89c --- /dev/null +++ b/server/src/core/public_id/mod.rs @@ -0,0 +1,369 @@ +//! 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 mongodb::bson::Bson; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +pub mod models; +pub use models::{ + 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. +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 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 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 { + inner: ObjectId, + _marker: PhantomData P>, +} + +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 / middleware layer only. + pub fn from_object_id(oid: ObjectId) -> Self { + Self { + inner: oid, + _marker: PhantomData, + } + } + + /// 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 + /// `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); + } + let oid = ObjectId::parse_str(body).map_err(|_| ParseIdError::InvalidHex)?; + Ok(Self { + inner: oid, + _marker: PhantomData, + }) + } + + /// 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. The `From<&T>` reference variant comes +// from bson's blanket `impl> From<&T> for Bson`. +impl From> for Bson { + fn from(id: Id

) -> Self { + Bson::ObjectId(id.inner) + } +} + +impl Copy for Id

{} +impl Clone for Id

{ + fn clone(&self) -> Self { + *self + } +} + +impl PartialEq for Id

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

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

{ + fn hash(&self, state: &mut H) { + self.inner.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.inner.cmp(&other.inner) + } +} + +impl fmt::Display for Id

{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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.inner.to_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 { + if serializer.is_human_readable() { + // JSON / OpenAPI / MCP wire format: prefixed string. + serializer.collect_str(&format_args!("{}_{}", P::PREFIX, self.inner.to_hex())) + } else { + // 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. + self.inner.serialize(serializer) + } + } +} + +impl<'de, P: IdPrefix> Deserialize<'de> for Id

{ + fn deserialize>(deserializer: D) -> Result { + 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)) + } + } +} + +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!(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 { + const PREFIX: &'static str = "pkid"; + const SCHEMA_NAME: &'static str = "PublishableKeyId"; +} + +crate::impl_container!(SecretKeyIdMarker); +pub struct SecretKeyIdMarker; +impl IdPrefix for SecretKeyIdMarker { + // `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"; +} + +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..ee4c2a8 --- /dev/null +++ b/server/src/core/public_id/models.rs @@ -0,0 +1,39 @@ +//! 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, AppUserIdMarker, AuthSessionIdMarker, ConversionEventIdMarker, + DomainIdMarker, Id, InstallEventIdMarker, LinkInternalIdMarker, OAuthSessionIdMarker, + 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 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; +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..c121d0d --- /dev/null +++ b/server/src/core/public_id/public_id_tests.rs @@ -0,0 +1,273 @@ +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::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::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::from_object_id(oid); + 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::from_object_id(oid); + let b = AffiliateId::from_object_id(oid); + assert_eq!(a, b); +} + +#[test] +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(); + 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::from_object_id(oid); + let clone = id; + let mut set = HashSet::new(); + set.insert(id); + 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: 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 { + 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); +} + +#[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(); +// let _w: WebhookId = a; // ERROR +// } 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 6e0bd0e..c36bda7 100644 --- a/server/src/mcp/server.rs +++ b/server/src/mcp/server.rs @@ -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() @@ -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, @@ -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() @@ -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 e47d74c..f743cbd 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). - #[schema(example = "665a1b2c3d4e5f6a7b8c9d0e")] + /// Credential id (the secret key id). + #[schema(example = "skid_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, @@ -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, @@ -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..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,6 +7,7 @@ use super::models::{ MAX_CREDENTIALS_PER_AFFILIATE, }; use super::repo::AffiliatesRepository; +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; @@ -66,7 +66,7 @@ impl AffiliatesService { let now = DateTime::now(); let affiliate = Affiliate { - id: ObjectId::new(), + id: AffiliateId::new(), tenant_id: ctx.tenant_id, name, partner_key: partner_key.clone(), @@ -90,7 +90,7 @@ 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) @@ -114,7 +114,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() { @@ -156,8 +156,8 @@ impl AffiliatesService { pub async fn mint_credential( &self, ctx: &AuthContext, - affiliate_id: ObjectId, - created_by: ObjectId, + affiliate_id: AffiliateId, + created_by: UserId, ) -> Result { // Affiliate must exist in this tenant. self.repo @@ -168,9 +168,10 @@ impl AffiliatesService { // 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 { @@ -199,7 +200,7 @@ 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 @@ -209,7 +210,10 @@ impl AffiliatesService { .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) } @@ -220,8 +224,8 @@ impl AffiliatesService { pub async fn revoke_credential( &self, ctx: &AuthContext, - affiliate_id: ObjectId, - key_id: ObjectId, + affiliate_id: AffiliateId, + key_id: SecretKeyId, ) -> Result<(), AffiliateError> { // Surface affiliate-not-found distinctly from credential-not-found. self.repo @@ -232,7 +236,11 @@ impl AffiliatesService { 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.to_object_id(), + ) .await .map_err(AffiliateError::Internal)?; @@ -252,7 +260,7 @@ impl AffiliatesService { pub async fn delete_affiliate( &self, ctx: &AuthContext, - affiliate_id: ObjectId, + affiliate_id: AffiliateId, ) -> Result<(), AffiliateError> { let deleted = self .repo diff --git a/server/src/services/analytics/service.rs b/server/src/services/analytics/service.rs index 53e07b9..eb9d9b3 100644 --- a/server/src/services/analytics/service.rs +++ b/server/src/services/analytics/service.rs @@ -59,7 +59,12 @@ impl AnalyticsService { // 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 81609f2..30fa814 100644 --- a/server/src/services/auth/permissions/context.rs +++ b/server/src/services/auth/permissions/context.rs @@ -2,14 +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::{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: ObjectId, user_id: ObjectId, session_id: ObjectId) -> Self { + pub fn for_session(tenant_id: TenantId, user_id: UserId, session_id: AuthSessionId) -> Self { Self { tenant_id, principal: Principal::User { @@ -25,8 +25,8 @@ 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, - key_id: ObjectId, + tenant_id: TenantId, + key_id: SecretKeyId, key_scope: Option<&KeyScope>, ) -> Self { let (permissions, resource_scope) = match key_scope { diff --git a/server/src/services/auth/permissions/context_tests.rs b/server/src/services/auth/permissions/context_tests.rs index 571e761..5a8c853 100644 --- a/server/src/services/auth/permissions/context_tests.rs +++ b/server/src/services/auth/permissions/context_tests.rs @@ -1,9 +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(ObjectId::new(), ObjectId::new(), ObjectId::new()) + AuthContext::for_session( + TenantId::new(), + UserId::new(), + crate::core::public_id::AuthSessionId::new(), + ) } #[test] @@ -16,22 +20,30 @@ 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(), + 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(ObjectId::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( - ObjectId::new(), - ObjectId::new(), + TenantId::new(), + crate::core::public_id::SecretKeyId::new(), Some(&KeyScope::Affiliate { affiliate_id }), ); assert!(ctx.require(Permission::LinksRead).is_ok()); @@ -40,18 +52,19 @@ 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 == affiliate_id + )); } #[test] fn require_any_succeeds_if_one_matches() { let ctx = AuthContext::for_secret_key( - ObjectId::new(), - ObjectId::new(), + TenantId::new(), + crate::core::public_id::SecretKeyId::new(), Some(&KeyScope::Affiliate { - affiliate_id: ObjectId::new(), + affiliate_id: crate::core::public_id::AffiliateId::new(), }), ); assert!(ctx @@ -62,10 +75,10 @@ fn require_any_succeeds_if_one_matches() { #[test] fn require_any_fails_when_none_match() { let ctx = AuthContext::for_secret_key( - ObjectId::new(), - ObjectId::new(), + TenantId::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 @@ -79,7 +92,11 @@ 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(), + 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 a40b847..28e0cbe 100644 --- a/server/src/services/auth/permissions/models.rs +++ b/server/src/services/auth/permissions/models.rs @@ -1,9 +1,10 @@ //! Data types for service-layer authorization. -use mongodb::bson::oid::ObjectId; use std::collections::BTreeSet; use std::fmt; +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 /// 403 error bodies and (future) OpenAPI security scope strings. @@ -65,15 +66,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, - session_id: ObjectId, + user_id: UserId, + session_id: AuthSessionId, }, /// `rl_live_…` secret key. - SecretKey { key_id: ObjectId }, + SecretKey { key_id: SecretKeyId }, } /// Which subset of the tenant's resources the caller can act on. Distinct @@ -81,10 +82,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 +93,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/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 acc689c..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 { @@ -99,7 +100,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)?; @@ -108,12 +109,12 @@ 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 .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,13 +209,17 @@ 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); } - 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), } @@ -225,7 +230,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)?; @@ -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 @@ -260,7 +269,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 +279,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.as_object_id()) .await .map_err(SecretKeyError::Internal)?; @@ -298,7 +307,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)?; if count >= 5 { 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 972bfe9..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, @@ -130,7 +136,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() @@ -143,7 +149,7 @@ impl UsersService { 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, @@ -200,14 +206,14 @@ 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)?; 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, @@ -218,10 +224,14 @@ 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) + .count_verified_by_tenant(ctx.tenant_id.as_object_id()) .await .map_err(UserError::Internal)?; @@ -231,7 +241,7 @@ impl UsersService { let deleted = self .users_repo - .delete(&ctx.tenant_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 d1426cf..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,16 +24,17 @@ 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::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 25d7ff7..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); @@ -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)?; @@ -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 fd35866..a2f869c 100644 --- a/server/src/services/billing/service_tests.rs +++ b/server/src/services/billing/service_tests.rs @@ -1,14 +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(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)] @@ -29,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()) } @@ -53,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(); @@ -63,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() @@ -77,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, @@ -94,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, @@ -113,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 723de33..9eac234 100644 --- a/server/src/services/domains/service.rs +++ b/server/src/services/domains/service.rs @@ -39,7 +39,11 @@ impl DomainsService { } 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 +61,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/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 28e97de..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; @@ -6,6 +5,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; @@ -77,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 { @@ -87,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)? { @@ -108,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) => { @@ -122,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)?; @@ -138,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 => { @@ -165,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); } @@ -180,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, @@ -205,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!( @@ -238,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, @@ -258,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, @@ -281,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, @@ -298,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"); @@ -315,7 +319,7 @@ impl LinksService { self.links_repo .record_attribute_event( - tenant_id, + tenant_oid, link_id, install_id, app_version, @@ -333,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,6 +359,7 @@ impl LinksService { req: CreateLinkRequest, ) -> Result { 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. @@ -387,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,6 +484,7 @@ impl LinksService { req: BulkCreateLinksRequest, ) -> Result { 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; @@ -586,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() @@ -608,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?; } @@ -681,7 +687,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 +695,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) { return Err(LinkError::NotFound); } } @@ -706,14 +712,17 @@ impl LinksService { limit: Option, cursor: Option, ) -> Result { - let tenant_id = &ctx.tenant_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}"); @@ -724,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())) @@ -750,7 +759,6 @@ impl LinksService { link_id: &str, req: UpdateLinkRequest, ) -> Result { - let tenant_id = &ctx.tenant_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()); @@ -835,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}"); @@ -850,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)?; @@ -862,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)?; @@ -893,7 +901,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}"); @@ -933,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)) @@ -949,10 +957,10 @@ 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)), @@ -979,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()) } @@ -995,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"); @@ -1044,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 b693d4e..a164780 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), + crate::core::public_id::SecretKeyId::new(), + Some(&KeyScope::Full), + ) } #[test] @@ -53,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, @@ -80,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, @@ -135,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, @@ -170,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()) } @@ -184,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); }; @@ -218,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( @@ -236,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()) @@ -337,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 24078b0..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,7 +28,7 @@ impl WebhooksService { pub async fn create_webhook( &self, ctx: &AuthContext, - id: ObjectId, + id: crate::core::public_id::WebhookId, url: String, secret: String, events: Vec, 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/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(); 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()