Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<P>`, 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<P>` (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**: `<prefix>_<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<FooIdMarker>;` 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<P>`.

### Cargo Features

- `api` — HTTP API routes (enabled by default)
Expand Down Expand Up @@ -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

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

Expand Down Expand Up @@ -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),
Expand All @@ -82,16 +82,13 @@ pub async fn list_affiliates(
pub async fn get_affiliate(
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.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),
}
Expand All @@ -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),
Expand All @@ -114,17 +111,14 @@ pub async fn get_affiliate(
pub async fn patch_affiliate(
State(state): State<Arc<AppState>>,
axum::Extension(ctx): axum::Extension<AuthContext>,
Path(affiliate_id): Path<String>,
Path(affiliate_id): Path<AffiliateId>,
Json(req): Json<UpdateAffiliateRequest>,
) -> 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),
}
Expand All @@ -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),
Expand All @@ -145,16 +139,13 @@ pub async fn patch_affiliate(
pub async fn delete_affiliate(
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.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),
}
Expand All @@ -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),
Expand All @@ -183,21 +174,25 @@ 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, 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
Expand All @@ -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),
Expand All @@ -227,21 +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, 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 @@ -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"),
Expand All @@ -270,19 +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, 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 All @@ -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,
Expand Down Expand Up @@ -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()
}
30 changes: 12 additions & 18 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.0,
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 @@ -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<AppDetail> = apps.iter().map(to_detail).collect();
Json(json!({ "apps": details })).into_response()
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.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,
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
Loading
Loading