Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
afa9612
Migrate domains, apps, conversions to typed Id<P>
saltyskip May 29, 2026
7b64db0
Migrate webhooks, app_users, install_events to typed Id<P>
saltyskip May 29, 2026
196101f
Migrate auth/{sessions,tenants,users,oauth}/models.rs + add session m…
saltyskip May 29, 2026
ce18128
Cleanup unused imports + remove sessions/oauth services from backlog
saltyskip May 29, 2026
5292bf3
Migrate affiliate credential routes + link CreateRequest affiliate_id
saltyskip May 29, 2026
7523025
Migrate secret_keys + publishable_keys models + their delete routes
saltyskip May 29, 2026
95176e3
Migrate links.Link, conversions.ConversionDedup, billing/auth/usage m…
saltyskip May 29, 2026
4b4d7f6
Drop analytics + publishable_keys routes + auth/secret_keys/lifecycle…
saltyskip May 29, 2026
1debb70
Migrate KeyScope::Affiliate.affiliate_id to AffiliateId
saltyskip May 29, 2026
a12ae83
Migrate AuthKeyId to wrap SecretKeyId + cached webhook lookup uses Te…
saltyskip May 29, 2026
95fcf3d
Migrate Principal::SecretKey.key_id + tenants::create_blank to typed
saltyskip May 29, 2026
a0cbc8f
Migrate links/routes lookup_tenant_domain + auth/users/routes + permi…
saltyskip May 29, 2026
8315962
Migrate sessions/service.revoke + conversions/routes path types
saltyskip May 29, 2026
04d119c
Migrate affiliates credential service methods to typed Ids
saltyskip May 29, 2026
0c822b0
Migrate auth/users service: delete + create_tenant_with_verified_owne…
saltyskip May 29, 2026
ba7d5f1
Middleware validate_api_key returns typed (TenantId, SecretKeyId)
saltyskip May 29, 2026
4b77569
Migrate stripe_webhook helpers to TenantId
saltyskip May 29, 2026
a19b56d
Migrate secret_keys service to typed TenantId/UserId/SecretKeyId
saltyskip May 29, 2026
b5546e7
Migrate QuotaChecker trait to typed TenantId
saltyskip May 29, 2026
78245a7
Migrate conversions service to typed TenantId/SourceId
saltyskip May 29, 2026
d656f9f
Migrate TierResolver trait + BillingService to typed TenantId
saltyskip May 29, 2026
dc5b51a
Migrate links models (ClickMeta, AttributionEventMeta, CreateLinkInpu…
saltyskip May 29, 2026
95ae0a9
Migrate links service to typed TenantId/AffiliateId — backlog drained…
saltyskip May 29, 2026
7470528
Docs: update prefixed-ID examples in OpenAPI + CLAUDE.md/AGENTS.md
saltyskip May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TenantId>`.
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<TenantId>`.

Public endpoints (landing page, attribution reporting) resolve the tenant from the link_id itself.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Post-install events (signups, purchases, deposits) flow through a **sources** ab
- **Attribution lookup** — events carry `user_id`; `ConversionsService::ingest_parsed` resolves `user_id → Attribution → link_id` via `LinksRepository::find_attribution_by_user` before inserting the event. Events with no matching attribution are logged and dropped.
- **Hard line** — the API answers link-scoped questions only. User-scoped queries (cohorts, funnels, retention) are permanently out of scope. Metadata is stored verbatim but not indexed or queried in v1.
- **Extensibility** — new integrations (RevenueCat, Stripe, etc.) are drop-in parser additions: implement `ConversionParser`, add a `SourceType` variant, add one line to `parser_for`. No schema migration required — `Source.signing_secret` and `Source.config` already exist for integration parsers to use.
- **Outbound webhook** — on successful ingestion, the service fires a `Conversion` webhook event with a stable `event_id` (the MongoDB ObjectId of the stored event) for customer-side dedup on retry. The webhook dispatcher's `find_active_for_event` query is wrapped in a 60-second `cached` layer to kill the per-event DB query hot path.
- **Outbound webhook** — on successful ingestion, the service fires a `Conversion` webhook event with a stable `event_id` (prefixed `cev_<24hex>` — the stored event's public id) for customer-side dedup on retry. The webhook dispatcher's `find_active_for_event` query is wrapped in a 60-second `cached` layer to kill the per-event DB query hot path.

## Adding a New Domain

Expand Down
55 changes: 16 additions & 39 deletions server/src/api/affiliates/routes.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -162,7 +161,7 @@ pub async fn delete_affiliate(
post,
path = "/v1/affiliates/{affiliate_id}/credentials",
tag = "Affiliates",
params(("affiliate_id" = String, Path, description = "Affiliate ObjectId")),
params(("affiliate_id" = String, Path, description = "Affiliate id")),
responses(
(status = 201, description = "Credential minted; api_key shown once", body = CreateAffiliateCredentialResponse),
(status = 403, description = "Caller scope cannot mint credentials", body = crate::error::ErrorResponse),
Expand All @@ -175,23 +174,24 @@ pub async fn create_affiliate_credential(
State(state): State<Arc<AppState>>,
axum::Extension(ctx): axum::Extension<AuthContext>,
axum::Extension(auth_key): axum::Extension<AuthKeyId>,
Path(affiliate_id): Path<String>,
Path(affiliate_id): Path<AffiliateId>,
) -> Response {
let Some(svc) = &state.affiliates_service else {
return no_database();
};
let Ok(oid) = ObjectId::parse_str(&affiliate_id) else {
return invalid_id();
};

match svc
.mint_credential(&ctx, AffiliateId::from_object_id(oid), auth_key.0)
.mint_credential(
&ctx,
affiliate_id,
crate::core::public_id::UserId::from_object_id(auth_key.0.to_object_id()),
)
.await
{
Ok(minted) => (
StatusCode::CREATED,
Json(CreateAffiliateCredentialResponse {
id: minted.created_key.id.to_hex(),
id: minted.created_key.id.to_string(),
affiliate_id: minted.affiliate_id,
api_key: minted.created_key.key,
key_prefix: minted.created_key.key_prefix,
Expand All @@ -211,7 +211,7 @@ pub async fn create_affiliate_credential(
get,
path = "/v1/affiliates/{affiliate_id}/credentials",
tag = "Affiliates",
params(("affiliate_id" = String, Path, description = "Affiliate ObjectId")),
params(("affiliate_id" = String, Path, description = "Affiliate id")),
responses(
(status = 200, description = "List of credentials (no raw secrets)", body = ListAffiliateCredentialsResponse),
(status = 404, description = "Affiliate not found", body = crate::error::ErrorResponse),
Expand All @@ -222,24 +222,18 @@ pub async fn create_affiliate_credential(
pub async fn list_affiliate_credentials(
State(state): State<Arc<AppState>>,
axum::Extension(ctx): axum::Extension<AuthContext>,
Path(affiliate_id): Path<String>,
Path(affiliate_id): Path<AffiliateId>,
) -> Response {
let Some(svc) = &state.affiliates_service else {
return no_database();
};
let Ok(oid) = ObjectId::parse_str(&affiliate_id) else {
return invalid_id();
};

match svc
.list_credentials(&ctx, AffiliateId::from_object_id(oid))
.await
{
match svc.list_credentials(&ctx, affiliate_id).await {
Ok(keys) => {
let creds: Vec<AffiliateCredentialDetail> = 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(),
})
Expand All @@ -255,8 +249,8 @@ pub async fn list_affiliate_credentials(
path = "/v1/affiliates/{affiliate_id}/credentials/{key_id}",
tag = "Affiliates",
params(
("affiliate_id" = String, Path, description = "Affiliate ObjectId"),
("key_id" = String, Path, description = "Credential ObjectId"),
("affiliate_id" = String, Path, description = "Affiliate id"),
("key_id" = String, Path, description = "Credential id"),
),
responses(
(status = 204, description = "Credential revoked"),
Expand All @@ -268,22 +262,13 @@ pub async fn list_affiliate_credentials(
pub async fn revoke_affiliate_credential(
State(state): State<Arc<AppState>>,
axum::Extension(ctx): axum::Extension<AuthContext>,
Path((affiliate_id, key_id)): Path<(String, String)>,
Path((affiliate_id, key_id)): Path<(AffiliateId, crate::core::public_id::SecretKeyId)>,
) -> Response {
let Some(svc) = &state.affiliates_service else {
return no_database();
};
let Ok(aff_oid) = ObjectId::parse_str(&affiliate_id) else {
return invalid_id();
};
let Ok(key_oid) = ObjectId::parse_str(&key_id) else {
return invalid_id();
};

match svc
.revoke_credential(&ctx, AffiliateId::from_object_id(aff_oid), key_oid)
.await
{
match svc.revoke_credential(&ctx, affiliate_id, key_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => affiliate_error_to_response(e),
}
Expand Down Expand Up @@ -337,11 +322,3 @@ fn no_database() -> Response {
)
.into_response()
}

fn invalid_id() -> Response {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Invalid ID", "code": "invalid_id" })),
)
.into_response()
}
28 changes: 11 additions & 17 deletions server/src/api/apps/routes.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -90,8 +89,8 @@ pub async fn create_app(
}

let app = crate::services::apps::models::App {
id: ObjectId::new(),
tenant_id: tenant.to_object_id(),
id: crate::core::public_id::AppId::new(),
tenant_id: tenant,
platform: platform.clone(),
bundle_id: req.bundle_id,
team_id: req.team_id,
Expand Down Expand Up @@ -180,7 +179,7 @@ pub async fn list_apps(
pub async fn delete_app(
State(state): State<Arc<AppState>>,
axum::Extension(tenant): axum::Extension<TenantId>,
Path(app_id): Path<String>,
Path(app_id): Path<crate::core::public_id::AppId>,
) -> Response {
let Some(repo) = &state.apps_repo else {
return (
Expand All @@ -190,15 +189,10 @@ pub async fn delete_app(
.into_response();
};

let Ok(oid) = ObjectId::parse_str(&app_id) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Invalid app_id", "code": "invalid_id" })),
)
.into_response();
};

match repo.delete_app(&tenant.to_object_id(), &oid).await {
match repo
.delete_app(&tenant.to_object_id(), &app_id.to_object_id())
.await
{
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Expand Down Expand Up @@ -246,7 +240,7 @@ pub async fn serve_aasa(State(state): State<Arc<AppState>>, 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()
Expand Down Expand Up @@ -315,7 +309,7 @@ pub async fn serve_assetlinks(State(state): State<Arc<AppState>>, 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()
Expand Down Expand Up @@ -357,7 +351,7 @@ pub async fn serve_assetlinks(State(state): State<Arc<AppState>>, headers: Heade
// ── Helpers ──

/// Resolve tenant from X-Rift-Host or Host header for custom domain routing.
async fn resolve_tenant_from_host(state: &Arc<AppState>, headers: &HeaderMap) -> Option<ObjectId> {
async fn resolve_tenant_from_host(state: &Arc<AppState>, headers: &HeaderMap) -> Option<TenantId> {
let host = headers
.get("x-rift-host")
.or_else(|| headers.get("host"))
Expand All @@ -380,7 +374,7 @@ async fn resolve_tenant_from_host(state: &Arc<AppState>, 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(),
Expand Down
49 changes: 24 additions & 25 deletions server/src/api/auth/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,11 +63,10 @@ pub async fn auth_gate(
}

// Inject tenant identity, key identity, and scope for downstream handlers.
req.extensions_mut()
.insert(TenantId::from_object_id(tenant_id));
req.extensions_mut().insert(tenant_id);
req.extensions_mut().insert(AuthKeyId(key_id));
req.extensions_mut().insert(AuthContext::for_secret_key(
TenantId::from_object_id(tenant_id),
tenant_id,
key_id,
scope.as_ref(),
));
Expand Down Expand Up @@ -215,14 +213,12 @@ pub async fn session_auth_gate(

match svc.lookup(&raw_token).await {
Ok(Some(resolved)) => {
req.extensions_mut()
.insert(TenantId::from_object_id(resolved.tenant_id));
req.extensions_mut()
.insert(UserId::from_object_id(resolved.user_id));
req.extensions_mut().insert(SessionId(resolved.session_id));
req.extensions_mut().insert(resolved.tenant_id);
req.extensions_mut().insert(resolved.user_id);
req.extensions_mut().insert(resolved.session_id);
req.extensions_mut().insert(AuthContext::for_session(
TenantId::from_object_id(resolved.tenant_id),
UserId::from_object_id(resolved.user_id),
resolved.tenant_id,
resolved.user_id,
resolved.session_id,
));

Expand Down Expand Up @@ -274,14 +270,12 @@ pub async fn session_or_key_auth_gate(
{
match svc.lookup(&raw_token).await {
Ok(Some(resolved)) => {
req.extensions_mut()
.insert(TenantId::from_object_id(resolved.tenant_id));
req.extensions_mut()
.insert(UserId::from_object_id(resolved.user_id));
req.extensions_mut().insert(SessionId(resolved.session_id));
req.extensions_mut().insert(resolved.tenant_id);
req.extensions_mut().insert(resolved.user_id);
req.extensions_mut().insert(resolved.session_id);
req.extensions_mut().insert(AuthContext::for_session(
TenantId::from_object_id(resolved.tenant_id),
UserId::from_object_id(resolved.user_id),
resolved.tenant_id,
resolved.user_id,
resolved.session_id,
));

Expand Down Expand Up @@ -330,11 +324,10 @@ pub async fn session_or_key_auth_gate(
}
}

req.extensions_mut()
.insert(TenantId::from_object_id(tenant_id));
req.extensions_mut().insert(tenant_id);
req.extensions_mut().insert(AuthKeyId(key_id));
req.extensions_mut().insert(AuthContext::for_secret_key(
TenantId::from_object_id(tenant_id),
tenant_id,
key_id,
scope.as_ref(),
));
Expand Down Expand Up @@ -450,8 +443,7 @@ pub async fn sdk_auth_gate(
.into_response();
}

req.extensions_mut()
.insert(TenantId::from_object_id(doc.tenant_id));
req.extensions_mut().insert(doc.tenant_id);
req.extensions_mut().insert(SdkDomain(doc.domain));

next.run(req).await
Expand Down Expand Up @@ -502,7 +494,14 @@ fn extract_sdk_query_key(req: &Request) -> Option<String> {
async fn validate_api_key(
secret_keys_repo: Option<&dyn SecretKeysRepository>,
raw_key: &str,
) -> Result<(ObjectId, ObjectId, Option<KeyScope>), Response> {
) -> Result<
(
crate::core::public_id::TenantId,
crate::core::public_id::SecretKeyId,
Option<KeyScope>,
),
Response,
> {
let hash = keys::hash_key(raw_key);

let sk_repo = secret_keys_repo.ok_or_else(|| {
Expand Down
26 changes: 9 additions & 17 deletions server/src/api/auth/models.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
//! Axum extension types injected by `api/auth/middleware.rs` into request
//! extensions, then extracted by route handlers via `Extension<...>`.
//!
//! `TenantId` and `UserId` are re-exports of the typed identifiers from
//! `core::public_id` — there's no separate axum-extension newtype. The
//! middleware constructs the typed value once at the auth boundary and the
//! same type flows all the way through services and repos.
//! `TenantId`, `UserId`, and `SessionId` (alias for `AuthSessionId`) are
//! re-exports of the typed identifiers from `core::public_id`. The middleware
//! constructs the typed value once at the auth boundary and the same type
//! flows all the way through services and repos.
//!
//! `AuthKeyId`, `SessionId`, `SdkDomain` are still local newtypes — they
//! aren't yet migrated to typed `Id<P>` aliases. Doing so is part of the
//! secret_keys / sessions migrations.
//! `AuthKeyId` and `SdkDomain` are still local newtypes — they aren't yet
//! migrated to typed `Id<P>` aliases (secret_keys migration pending).

use mongodb::bson::oid::ObjectId;
pub use crate::core::public_id::{AuthSessionId as SessionId, SecretKeyId, TenantId, UserId};

pub use crate::core::public_id::{TenantId, UserId};

/// The ObjectId of the secret key used for authentication.
/// Identifier of the secret key used for authentication.
/// Handlers extract this via `Extension<AuthKeyId>`.
#[derive(Debug, Clone)]
pub struct AuthKeyId(pub ObjectId);

/// The active session's ObjectId — used by `POST /v1/auth/signout` to revoke
/// the exact session the caller arrived on.
#[derive(Debug, Clone)]
pub struct SessionId(pub ObjectId);
pub struct AuthKeyId(pub SecretKeyId);

/// Domain associated with an SDK key, injected by `sdk_auth_gate`.
#[derive(Debug, Clone)]
Expand Down
Loading