Select the types of activity you want to include in your feed.
Magazi is a content distribution platform that gates access to files using ATProtocol (Bluesky) identity and cryptographic proofs.
download.ngerakines.me/
···11+//! Build script for cargo rerun triggers.
22+33+fn main() {
44+ // Rerun if templates change
55+ println!("cargo:rerun-if-changed=templates");
66+}
+32
files/catalog.json
···11+[
22+ {
33+ "id": "bafkreif2gmzhha3eiaznudhp5lthedtbo3bsgzksrymbeoisfun5rwbyqi",
44+ "name": "Resume",
55+ "description": "This is my resume as of October 2025.",
66+ "content_type": "application/pdf",
77+ "icon": "fa-solid fa-briefcase",
88+ "requirements": {}
99+ },
1010+ {
1111+ "id": "bafkreiciye5m6bpyv6gvenub7f75eo7ifouheiy4evsk6pklwrtpsmaiqi",
1212+ "name": "Hello from me and gadget",
1313+ "description": "A picture with a very cute person, and then me. You don't need to be a supporter to download this digital content, just logged in.",
1414+ "content_type": "image/jpeg",
1515+ "icon": "fa-regular fa-file-image",
1616+ "requirements": {
1717+ "==": [
1818+ {
1919+ "var": "authenticated"
2020+ },
2121+ true
2222+ ]
2323+ }
2424+ },
2525+ {
2626+ "id": "bafkreib7xwfunygo4nuav24wjzqdyeo3734eovvgzkwcpui2uw4gkj2cwm",
2727+ "name": "ATProtocol OAuth Implementation Agent Skill",
2828+ "description": "The atprotocol-oauth skill is a comprehensive prompt that can be used to fully implement ATProtocol OAuth in your backend web application. Additionally, it can be used to validate an implementation to ensure it is up to spec.",
2929+ "content_type": "text/markdown",
3030+ "icon": "fa-brands fa-markdown"
3131+ }
3232+]
+118
src/bin/catalog-manager.rs
···11+//! Catalog manager binary for adding entries to the catalog.
22+//!
33+//! Usage: catalog-manager <name> <file-path> <description> [icon]
44+//!
55+//! Creates or updates entries in ./files/catalog.json with the format:
66+//! {"id": "[raw CID of file]", "name": "[name]", "description": "[description]", "content_type": "[detected]", "icon": "[optional]"}
77+//!
88+//! The content_type is automatically detected from the file contents using mimetype-detector.
99+//! The icon is an optional FontAwesome class (e.g., "fa-solid fa-book").
1010+//!
1111+//! Also copies the file to ./files/[cid]
1212+1313+use std::env;
1414+use std::fs;
1515+use std::path::Path;
1616+1717+use cid::Cid;
1818+use multihash_codetable::{Code, MultihashDigest};
1919+use serde::{Deserialize, Serialize};
2020+2121+/// Raw codec for CID (0x55)
2222+const RAW_CODEC: u64 = 0x55;
2323+2424+#[derive(Debug, Clone, Serialize, Deserialize)]
2525+struct CatalogEntry {
2626+ id: String,
2727+ name: String,
2828+ description: String,
2929+ #[serde(skip_serializing_if = "Option::is_none")]
3030+ content_type: Option<String>,
3131+ #[serde(skip_serializing_if = "Option::is_none")]
3232+ icon: Option<String>,
3333+}
3434+3535+fn compute_raw_cid(data: &[u8]) -> String {
3636+ let hash = Code::Sha2_256.digest(data);
3737+ let cid = Cid::new_v1(RAW_CODEC, hash);
3838+ cid.to_string()
3939+}
4040+4141+fn main() -> anyhow::Result<()> {
4242+ let args: Vec<String> = env::args().collect();
4343+4444+ if args.len() < 4 || args.len() > 5 {
4545+ eprintln!("Usage: {} <name> <file-path> <description> [icon]", args[0]);
4646+ eprintln!();
4747+ eprintln!("Arguments:");
4848+ eprintln!(" name Name of the catalog entry");
4949+ eprintln!(" file-path Path to the file to add");
5050+ eprintln!(" description Description of the catalog entry");
5151+ eprintln!(" icon Optional FontAwesome icon class (e.g., \"fa-solid fa-book\")");
5252+ eprintln!();
5353+ eprintln!("The content type is automatically detected from the file contents.");
5454+ std::process::exit(1);
5555+ }
5656+5757+ let name = &args[1];
5858+ let file_path = &args[2];
5959+ let description = &args[3];
6060+ let icon = args.get(4).cloned();
6161+6262+ let file_path = Path::new(file_path);
6363+ if !file_path.exists() {
6464+ eprintln!("Error: File not found: {}", file_path.display());
6565+ std::process::exit(1);
6666+ }
6767+6868+ let file_contents = fs::read(file_path)?;
6969+ let cid = compute_raw_cid(&file_contents);
7070+7171+ // Detect MIME type from file contents
7272+ let detected_mime = mimetype_detector::detect(&file_contents);
7373+ let content_type = detected_mime.mime().to_string();
7474+ println!("Detected content type: {}", content_type);
7575+7676+ // Ensure ./files/ directory exists and copy file
7777+ let files_dir = Path::new("./files");
7878+ fs::create_dir_all(files_dir)?;
7979+8080+ let dest_path = files_dir.join(&cid);
8181+ fs::write(&dest_path, &file_contents)?;
8282+ println!("Copied file to {}", dest_path.display());
8383+8484+ let new_entry = CatalogEntry {
8585+ id: cid.clone(),
8686+ name: name.clone(),
8787+ description: description.clone(),
8888+ content_type: Some(content_type),
8989+ icon: icon.clone(),
9090+ };
9191+9292+ let catalog_path = files_dir.join("catalog.json");
9393+9494+ let mut catalog: Vec<CatalogEntry> = if catalog_path.exists() {
9595+ let contents = fs::read_to_string(&catalog_path)?;
9696+ serde_json::from_str(&contents).unwrap_or_default()
9797+ } else {
9898+ Vec::new()
9999+ };
100100+101101+ if let Some(existing) = catalog.iter_mut().find(|e| e.id == cid) {
102102+ existing.name = new_entry.name;
103103+ existing.description = new_entry.description;
104104+ existing.content_type = new_entry.content_type;
105105+ existing.icon = new_entry.icon;
106106+ println!("Updated existing entry with CID: {}", cid);
107107+ } else {
108108+ catalog.push(new_entry);
109109+ println!("Added new entry with CID: {}", cid);
110110+ }
111111+112112+ let json = serde_json::to_string_pretty(&catalog)?;
113113+ fs::write(&catalog_path, json)?;
114114+115115+ println!("Catalog saved to {}", catalog_path.display());
116116+117117+ Ok(())
118118+}
+104
src/config.rs
···11+use std::fs;
22+use std::path::PathBuf;
33+44+use anyhow::{Context, Result};
55+use atproto_identity::key::{KeyData, identify_key};
66+use serde::{Deserialize, Serialize};
77+88+use crate::entitlements::default_requirements;
99+1010+/// A catalog entry from catalog.json (without download URL).
1111+#[derive(Clone, Debug, Serialize, Deserialize)]
1212+pub struct CatalogEntry {
1313+ pub id: String,
1414+ pub name: String,
1515+ pub description: String,
1616+ /// Optional content type override for download and getBlob responses.
1717+ /// When set, this value is used instead of mime_guess inference.
1818+ #[serde(skip_serializing_if = "Option::is_none")]
1919+ pub content_type: Option<String>,
2020+ /// Optional FontAwesome icon class (e.g., "fa-solid fa-book").
2121+ /// When not set, defaults to "fa-regular fa-file".
2222+ #[serde(skip_serializing_if = "Option::is_none")]
2323+ pub icon: Option<String>,
2424+ /// JSONLogic rule for determining entitlement to this catalog item.
2525+ /// When not set, uses the default requirements (authenticated + supporter proof + broker proof).
2626+ #[serde(default = "default_requirements")]
2727+ pub requirements: serde_json::Value,
2828+}
2929+3030+#[derive(Clone)]
3131+pub struct Config {
3232+ pub http_port: u16,
3333+ pub http_external: String,
3434+ pub cookie_key: Vec<u8>,
3535+ pub oauth_client_credentials: KeyData,
3636+ pub creator_identity: String,
3737+ pub broker_identity: String,
3838+ pub download_key: KeyData,
3939+ pub files_path: PathBuf,
4040+ pub catalog: Vec<CatalogEntry>,
4141+}
4242+4343+impl Config {
4444+ pub fn from_env() -> Result<Self> {
4545+ let http_port: u16 = std::env::var("HTTP_PORT")
4646+ .context("HTTP_PORT not set")?
4747+ .parse()
4848+ .context("HTTP_PORT must be a valid port number")?;
4949+5050+ let http_external = std::env::var("HTTP_EXTERNAL").context("HTTP_EXTERNAL not set")?;
5151+5252+ let cookie_key_b64 = std::env::var("HTTP_COOKIE_KEY").context("HTTP_COOKIE_KEY not set")?;
5353+ let cookie_key =
5454+ base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &cookie_key_b64)
5555+ .context("HTTP_COOKIE_KEY must be valid base64")?;
5656+5757+ let oauth_client_credentials_str = std::env::var("OAUTH_CLIENT_CREDENTIALS")
5858+ .context("OAUTH_CLIENT_CREDENTIALS not set")?;
5959+ let oauth_client_credentials = identify_key(&oauth_client_credentials_str)
6060+ .map_err(|e| anyhow::anyhow!("Invalid OAUTH_CLIENT_CREDENTIALS: {}", e))?;
6161+6262+ let creator_identity =
6363+ std::env::var("CREATOR_IDENTITY").context("CREATOR_IDENTITY not set")?;
6464+6565+ let broker_identity =
6666+ std::env::var("BROKER_IDENTITY").context("BROKER_IDENTITY not set")?;
6767+6868+ let download_key_str = std::env::var("DOWNLOAD_KEY").context("DOWNLOAD_KEY not set")?;
6969+ let download_key = identify_key(&download_key_str)
7070+ .map_err(|e| anyhow::anyhow!("Invalid DOWNLOAD_KEY: {}", e))?;
7171+7272+ let files_path = PathBuf::from(std::env::var("FILES").context("FILES not set")?);
7373+7474+ let catalog_path = PathBuf::from(std::env::var("CATALOG").context("CATALOG not set")?);
7575+ let catalog_contents = fs::read_to_string(&catalog_path)
7676+ .with_context(|| format!("Failed to read catalog file: {}", catalog_path.display()))?;
7777+ let catalog: Vec<CatalogEntry> = serde_json::from_str(&catalog_contents)
7878+ .with_context(|| format!("Failed to parse catalog file: {}", catalog_path.display()))?;
7979+8080+ Ok(Config {
8181+ http_port,
8282+ http_external,
8383+ cookie_key,
8484+ oauth_client_credentials,
8585+ creator_identity,
8686+ broker_identity,
8787+ download_key,
8888+ files_path,
8989+ catalog,
9090+ })
9191+ }
9292+9393+ pub fn client_id(&self) -> String {
9494+ format!("https://{}/oauth-client-metadata.json", self.http_external)
9595+ }
9696+9797+ pub fn redirect_uri(&self) -> String {
9898+ format!("https://{}/login/callback", self.http_external)
9999+ }
100100+101101+ pub fn external_url(&self) -> String {
102102+ format!("https://{}", self.http_external)
103103+ }
104104+}
+96
src/entitlements.rs
···11+use serde::{Deserialize, Serialize};
22+33+/// Context for evaluating entitlement rules.
44+///
55+/// This struct is serialized to JSON and passed to JSONLogic rule evaluation.
66+/// Each field becomes a variable that can be referenced in rules using `{"var": "fieldName"}`.
77+///
88+/// Note: All fields are always serialized (including null values) to ensure
99+/// JSONLogic comparisons work correctly.
1010+#[derive(Debug, Clone, Serialize, Deserialize)]
1111+#[serde(rename_all = "camelCase")]
1212+pub struct EntitlementContext {
1313+ /// Whether the user is authenticated
1414+ pub authenticated: bool,
1515+1616+ /// The user's DID (if authenticated)
1717+ pub did: Option<String>,
1818+1919+ /// The user's handle (if authenticated)
2020+ pub handle: Option<String>,
2121+2222+ /// Full supporter record from the user's PDS
2323+ pub supporter: Option<serde_json::Value>,
2424+2525+ /// Full proof record from the creator (fetched via StrongRef)
2626+ pub supporter_proof: Option<serde_json::Value>,
2727+2828+ /// Full proof record from the broker (fetched via StrongRef)
2929+ pub broker_proof: Option<serde_json::Value>,
3030+}
3131+3232+impl EntitlementContext {
3333+ /// Create an unauthenticated context
3434+ pub fn unauthenticated() -> Self {
3535+ Self {
3636+ authenticated: false,
3737+ did: None,
3838+ handle: None,
3939+ supporter: None,
4040+ supporter_proof: None,
4141+ broker_proof: None,
4242+ }
4343+ }
4444+4545+ /// Create an authenticated context without supporter records
4646+ ///
4747+ /// This is used for logged-in users who don't have any supporter records
4848+ /// but should still be able to access content that only requires authentication.
4949+ pub fn authenticated(did: String, handle: Option<String>) -> Self {
5050+ Self {
5151+ authenticated: true,
5252+ did: Some(did),
5353+ handle,
5454+ supporter: None,
5555+ supporter_proof: None,
5656+ broker_proof: None,
5757+ }
5858+ }
5959+6060+ /// Create a full context with supporter records
6161+ pub fn with_supporter(
6262+ did: String,
6363+ handle: Option<String>,
6464+ supporter: serde_json::Value,
6565+ supporter_proof: serde_json::Value,
6666+ broker_proof: serde_json::Value,
6767+ ) -> Self {
6868+ Self {
6969+ authenticated: true,
7070+ did: Some(did),
7171+ handle,
7272+ supporter: Some(supporter),
7373+ supporter_proof: Some(supporter_proof),
7474+ broker_proof: Some(broker_proof),
7575+ }
7676+ }
7777+}
7878+7979+/// Returns the default requirements rule for catalog entries.
8080+///
8181+/// This rule requires:
8282+/// - User must be authenticated
8383+/// - User must have a valid supporterProof from the creator
8484+/// - User must have a valid brokerProof from the broker
8585+///
8686+/// Uses the `!!` (double-bang) operator to check for truthiness, which is
8787+/// safer than null comparisons across different JSONLogic implementations.
8888+pub fn default_requirements() -> serde_json::Value {
8989+ serde_json::json!({
9090+ "and": [
9191+ {"==": [{"var": "authenticated"}, true]},
9292+ {"!!": [{"var": "supporterProof"}]},
9393+ {"!!": [{"var": "brokerProof"}]}
9494+ ]
9595+ })
9696+}
···11+use std::sync::Arc;
22+33+use atproto_identity::key::to_public;
44+use axum::{
55+ extract::State,
66+ http::StatusCode,
77+ response::{IntoResponse, Json, Response},
88+};
99+use p256::elliptic_curve::sec1::ToEncodedPoint as _;
1010+use serde_json::{Value, json};
1111+1212+use crate::state::AppState;
1313+1414+pub async fn client_metadata(State(state): State<Arc<AppState>>) -> Response {
1515+ let public_key = match to_public(&state.config.oauth_client_credentials) {
1616+ Ok(key) => key,
1717+ Err(e) => {
1818+ tracing::error!("Failed to derive public key: {}", e);
1919+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
2020+ }
2121+ };
2222+ let public_key_str = public_key.to_string();
2323+2424+ let jwk = match key_data_to_jwk(&state.config.oauth_client_credentials, &public_key_str) {
2525+ Ok(jwk) => jwk,
2626+ Err(e) => {
2727+ tracing::error!("Failed to convert key to JWK: {}", e);
2828+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
2929+ }
3030+ };
3131+3232+ Json(json!({
3333+ "application_type": "web",
3434+ "client_id": state.config.client_id(),
3535+ "client_name": "Magazi",
3636+ "client_uri": state.config.external_url(),
3737+ "dpop_bound_access_tokens": true,
3838+ "grant_types": [
3939+ "authorization_code",
4040+ "refresh_token"
4141+ ],
4242+ "jwks": {
4343+ "keys": [jwk]
4444+ },
4545+ "redirect_uris": [
4646+ state.config.redirect_uri()
4747+ ],
4848+ "response_types": [
4949+ "code"
5050+ ],
5151+ "scope": "atproto",
5252+ "token_endpoint_auth_method": "private_key_jwt",
5353+ "token_endpoint_auth_signing_alg": "ES256"
5454+ }))
5555+ .into_response()
5656+}
5757+5858+fn key_data_to_jwk(
5959+ key_data: &atproto_identity::key::KeyData,
6060+ kid: &str,
6161+) -> Result<Value, &'static str> {
6262+ use atproto_identity::key::to_public;
6363+ use base64::Engine;
6464+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6565+6666+ let public_key = to_public(key_data).map_err(|_| "Failed to derive public key")?;
6767+ let bytes = public_key.bytes();
6868+6969+ if bytes.len() == 65 && bytes[0] == 0x04 {
7070+ let x = &bytes[1..33];
7171+ let y = &bytes[33..65];
7272+7373+ Ok(json!({
7474+ "kty": "EC",
7575+ "crv": "P-256",
7676+ "alg": "ES256",
7777+ "use": "sig",
7878+ "kid": kid,
7979+ "x": URL_SAFE_NO_PAD.encode(x),
8080+ "y": URL_SAFE_NO_PAD.encode(y)
8181+ }))
8282+ } else if bytes.len() == 33 {
8383+ use p256::elliptic_curve::sec1::FromEncodedPoint;
8484+ use p256::{EncodedPoint, PublicKey};
8585+8686+ let encoded_point =
8787+ EncodedPoint::from_bytes(bytes).map_err(|_| "Invalid compressed point")?;
8888+ let public_key = PublicKey::from_encoded_point(&encoded_point)
8989+ .into_option()
9090+ .ok_or("Failed to decompress point")?;
9191+ let uncompressed = public_key.to_encoded_point(false);
9292+ let uncompressed_bytes = uncompressed.as_bytes();
9393+9494+ let x = &uncompressed_bytes[1..33];
9595+ let y = &uncompressed_bytes[33..65];
9696+9797+ Ok(json!({
9898+ "kty": "EC",
9999+ "crv": "P-256",
100100+ "alg": "ES256",
101101+ "use": "sig",
102102+ "kid": kid,
103103+ "x": URL_SAFE_NO_PAD.encode(x),
104104+ "y": URL_SAFE_NO_PAD.encode(y)
105105+ }))
106106+ } else {
107107+ Err("Unexpected key format")
108108+ }
109109+}
+8
src/handlers/mod.rs
···11+pub mod handler_auth;
22+pub mod handler_download;
33+pub mod handler_getblob;
44+pub mod handler_home;
55+pub mod handler_oauth;
66+77+pub mod util_entitlements;
88+pub mod util_errors;
+326
src/handlers/util_entitlements.rs
···11+use std::collections::HashMap;
22+33+use atproto_attestation::{AnyInput, verify_record};
44+use atproto_client::RecordResolver;
55+use atproto_client::client::Auth;
66+use atproto_client::com::atproto::repo::{ListRecordsParams, list_records};
77+use atproto_record::lexicon::com::atproto::repo::TypedStrongRef;
88+use atproto_record::typed::{LexiconType, TypedLexicon};
99+use datalogic_rs::DataLogic;
1010+use serde::{Deserialize, Serialize};
1111+1212+use crate::config::CatalogEntry;
1313+use crate::entitlements::EntitlementContext;
1414+use crate::resolvers::DidKeyResolver;
1515+use crate::state::AppState;
1616+1717+/// Cache key used for anonymous (unauthenticated) visitors
1818+const ANONYMOUS_CACHE_KEY: &str = "";
1919+2020+#[derive(Serialize, Deserialize, Clone, PartialEq)]
2121+struct SupporterProof {
2222+ cid: String,
2323+ signature: String,
2424+ #[serde(flatten)]
2525+ extra: HashMap<String, serde_json::Value>,
2626+}
2727+2828+impl LexiconType for SupporterProof {
2929+ fn lexicon_type() -> &'static str {
3030+ "com.atprotofans.supporterProof"
3131+ }
3232+}
3333+3434+#[derive(Serialize, Deserialize, Clone, PartialEq)]
3535+struct BrokerProof {
3636+ cid: String,
3737+ signature: String,
3838+ #[serde(flatten)]
3939+ extra: HashMap<String, serde_json::Value>,
4040+}
4141+4242+impl LexiconType for BrokerProof {
4343+ fn lexicon_type() -> &'static str {
4444+ "com.atprotofans.brokerProof"
4545+ }
4646+}
4747+4848+type TypedSupporterProof = TypedLexicon<SupporterProof>;
4949+type TypedBrokerProof = TypedLexicon<BrokerProof>;
5050+5151+#[derive(Serialize, Deserialize, Clone, PartialEq)]
5252+#[serde(untagged)]
5353+enum SignatureOrRef {
5454+ SupporterProof(TypedSupporterProof),
5555+ BrokerProof(TypedBrokerProof),
5656+ StrongRef(TypedStrongRef),
5757+}
5858+5959+#[derive(Serialize, Deserialize, PartialEq)]
6060+struct SupporterRecord {
6161+ subject: String,
6262+ #[serde(flatten)]
6363+ extra: HashMap<String, serde_json::Value>,
6464+ #[serde(default)]
6565+ signatures: Vec<SignatureOrRef>,
6666+}
6767+6868+impl LexiconType for SupporterRecord {
6969+ fn lexicon_type() -> &'static str {
7070+ "com.atprotofans.supporter"
7171+ }
7272+}
7373+7474+type TypedSupporterRecord = TypedLexicon<SupporterRecord>;
7575+7676+/// Build entitlement contexts for a user by fetching their supporter records
7777+/// and resolving all StrongRef proofs.
7878+///
7979+/// Returns a list of contexts, one for each valid supporter record that has
8080+/// both a supporterProof and brokerProof StrongRef that can be resolved.
8181+pub async fn get_entitlement_contexts(
8282+ state: &AppState,
8383+ did: &str,
8484+ handle: Option<&str>,
8585+ pds_url: &str,
8686+) -> Vec<EntitlementContext> {
8787+ // Check the context cache first
8888+ if let Some(cached) = state.context_cache.get(did).await {
8989+ return cached;
9090+ }
9191+9292+ let contexts = build_entitlement_contexts(state, did, handle, pds_url).await;
9393+9494+ // Cache the result
9595+ state
9696+ .context_cache
9797+ .insert(did.to_string(), contexts.clone())
9898+ .await;
9999+100100+ contexts
101101+}
102102+103103+async fn build_entitlement_contexts(
104104+ state: &AppState,
105105+ did: &str,
106106+ handle: Option<&str>,
107107+ pds_url: &str,
108108+) -> Vec<EntitlementContext> {
109109+ // Always include a base authenticated context for logged-in users.
110110+ // This ensures that rules checking only `authenticated: true` will match
111111+ // even if the user has no supporter records.
112112+ let mut contexts = vec![EntitlementContext::authenticated(
113113+ did.to_string(),
114114+ handle.map(|s| s.to_string()),
115115+ )];
116116+117117+ let params = ListRecordsParams::new().limit(100);
118118+119119+ let records = match list_records::<TypedSupporterRecord>(
120120+ &state.http_client,
121121+ &Auth::None,
122122+ pds_url,
123123+ did.to_string(),
124124+ "com.atprotofans.supporter".to_string(),
125125+ params,
126126+ )
127127+ .await
128128+ {
129129+ Ok(r) => r,
130130+ Err(e) => {
131131+ tracing::error!("Failed to list supporter records: {}", e);
132132+ return contexts;
133133+ }
134134+ };
135135+136136+ for record in records.records {
137137+ // Find StrongRef for supporterProof from creator
138138+ let supporter_proof_ref = find_strong_ref(
139139+ &record.value,
140140+ &state.config.creator_identity,
141141+ "com.atprotofans.supporterProof",
142142+ );
143143+144144+ // Find StrongRef for brokerProof from broker
145145+ let broker_proof_ref = find_strong_ref(
146146+ &record.value,
147147+ &state.config.broker_identity,
148148+ "com.atprotofans.brokerProof",
149149+ );
150150+151151+ // Skip records that don't have both StrongRefs
152152+ let (supporter_ref, broker_ref) = match (supporter_proof_ref, broker_proof_ref) {
153153+ (Some(sp), Some(bp)) => (sp, bp),
154154+ _ => continue,
155155+ };
156156+157157+ // Verify the attestation on the supporter record
158158+ let verify_result = verify_record(
159159+ AnyInput::Serialize(&record.value),
160160+ did,
161161+ DidKeyResolver::new(state.identity_resolver.clone()),
162162+ (*state.record_resolver).clone(),
163163+ )
164164+ .await;
165165+166166+ if verify_result.is_err() {
167167+ continue;
168168+ }
169169+170170+ // Fetch the actual proof records via the StrongRefs
171171+ let supporter_proof: Option<serde_json::Value> =
172172+ match state.record_resolver.resolve(&supporter_ref).await {
173173+ Ok(proof) => Some(proof),
174174+ Err(_) => None,
175175+ };
176176+177177+ let broker_proof: Option<serde_json::Value> =
178178+ match state.record_resolver.resolve(&broker_ref).await {
179179+ Ok(proof) => Some(proof),
180180+ Err(_) => None,
181181+ };
182182+183183+ // Only create context if both proofs were fetched successfully
184184+ if let (Some(sp), Some(bp)) = (supporter_proof, broker_proof) {
185185+ // Convert the supporter record to a JSON value
186186+ let supporter_json =
187187+ serde_json::to_value(&record.value).unwrap_or(serde_json::Value::Null);
188188+189189+ contexts.push(EntitlementContext::with_supporter(
190190+ did.to_string(),
191191+ handle.map(|s| s.to_string()),
192192+ supporter_json,
193193+ sp,
194194+ bp,
195195+ ));
196196+ }
197197+ }
198198+199199+ contexts
200200+}
201201+202202+/// Find a StrongRef in the signatures array that points to the expected authority and collection.
203203+fn find_strong_ref(
204204+ record: &TypedSupporterRecord,
205205+ expected_authority: &str,
206206+ collection: &str,
207207+) -> Option<String> {
208208+ for sig in &record.inner.signatures {
209209+ if let SignatureOrRef::StrongRef(strong_ref) = sig {
210210+ let expected_prefix = format!("at://{}/{}/", expected_authority, collection);
211211+ if strong_ref.inner.uri.starts_with(&expected_prefix) {
212212+ return Some(strong_ref.inner.uri.clone());
213213+ }
214214+ }
215215+ }
216216+ None
217217+}
218218+219219+/// Check if a user is entitled to a specific catalog entry by evaluating its
220220+/// JSONLogic requirements against each of their entitlement contexts.
221221+///
222222+/// Returns true if ANY context satisfies the requirements.
223223+pub async fn check_entitlement(
224224+ state: &AppState,
225225+ contexts: &[EntitlementContext],
226226+ catalog_entry: &CatalogEntry,
227227+) -> bool {
228228+ // Determine cache key - use empty string for anonymous users
229229+ let cache_did = contexts
230230+ .first()
231231+ .and_then(|ctx| ctx.did.clone())
232232+ .unwrap_or_else(|| ANONYMOUS_CACHE_KEY.to_string());
233233+234234+ let cache_key = (cache_did.clone(), catalog_entry.id.clone());
235235+236236+ // Check the entitlement cache first
237237+ if let Some(cached) = state.entitlement_cache.get(&cache_key).await {
238238+ return cached;
239239+ }
240240+241241+ let result = evaluate_entitlement(contexts, catalog_entry);
242242+243243+ // Cache the result
244244+ state.entitlement_cache.insert(cache_key, result).await;
245245+246246+ result
247247+}
248248+249249+fn evaluate_entitlement(contexts: &[EntitlementContext], catalog_entry: &CatalogEntry) -> bool {
250250+ let engine = DataLogic::new();
251251+252252+ // Compile the rule once
253253+ let compiled = match engine.compile(&catalog_entry.requirements) {
254254+ Ok(c) => c,
255255+ Err(e) => {
256256+ tracing::error!(
257257+ catalog_id = %catalog_entry.id,
258258+ error = %e,
259259+ "Failed to compile JSONLogic rule"
260260+ );
261261+ return false;
262262+ }
263263+ };
264264+265265+ // If no contexts provided (unauthenticated), create an unauthenticated context
266266+ let contexts_to_check: Vec<EntitlementContext> = if contexts.is_empty() {
267267+ vec![EntitlementContext::unauthenticated()]
268268+ } else {
269269+ contexts.to_vec()
270270+ };
271271+272272+ // Check each context against the requirements
273273+ for context in contexts_to_check.iter() {
274274+ let context_json = match serde_json::to_value(context) {
275275+ Ok(v) => v,
276276+ Err(e) => {
277277+ tracing::error!("Failed to serialize context: {}", e);
278278+ continue;
279279+ }
280280+ };
281281+282282+ match engine.evaluate_owned(&compiled, context_json) {
283283+ Ok(result) => {
284284+ // JSONLogic returns a JSON value, check if it's truthy
285285+ if is_truthy(&result) {
286286+ return true;
287287+ }
288288+ }
289289+ Err(e) => {
290290+ tracing::error!(
291291+ catalog_id = %catalog_entry.id,
292292+ error = %e,
293293+ "Failed to evaluate JSONLogic rule"
294294+ );
295295+ }
296296+ }
297297+ }
298298+299299+ false
300300+}
301301+302302+/// Check if a JSON value is truthy according to JSONLogic semantics.
303303+fn is_truthy(value: &serde_json::Value) -> bool {
304304+ match value {
305305+ serde_json::Value::Null => false,
306306+ serde_json::Value::Bool(b) => *b,
307307+ serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
308308+ serde_json::Value::String(s) => !s.is_empty(),
309309+ serde_json::Value::Array(a) => !a.is_empty(),
310310+ serde_json::Value::Object(_) => true,
311311+ }
312312+}
313313+314314+/// Pre-populate the entitlement cache for anonymous visitors.
315315+///
316316+/// This should be called on application startup to ensure the first
317317+/// anonymous visitor gets a fast response.
318318+pub async fn warmup_anonymous_cache(state: &AppState) {
319319+ let empty_contexts: Vec<EntitlementContext> = vec![];
320320+321321+ for entry in &state.config.catalog {
322322+ let result = evaluate_entitlement(&empty_contexts, entry);
323323+ let cache_key = (ANONYMOUS_CACHE_KEY.to_string(), entry.id.clone());
324324+ state.entitlement_cache.insert(cache_key, result).await;
325325+ }
326326+}