Skip to main content

pharos_native/runtime/
social_connections.rs

1//! Framework-owned external-account controllers. These routes never issue a local login session.
2
3use super::*;
4use pharos_runtime_oauth::{CallbackParameters, Cancellation, SecretValue, Url, wire};
5use pharos_runtime_security::credentials::EncryptedFileCredentialBackend;
6use pharos_runtime_social::{
7    SocialError,
8    connection::{ConnectionScope, PendingConnection, SocialClient},
9    provider::SocialProvider,
10    repository::{ConnectionRecord, ConnectionRepository},
11};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14
15const PREFIX: &str = "/__pharos/social/";
16#[cfg(unix)]
17mod browser;
18mod consent_ui;
19mod settings;
20
21static OPERATOR_CONFIG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
22
23/// Retains only the trusted serving configuration path so isolated previews can find operator registrations.
24pub(super) fn install_operator_config(config: &ServeConfig) -> Result<(), &'static str> {
25    *OPERATOR_CONFIG
26        .write()
27        .map_err(|_| "social operator configuration lock failed")? = config.conf_path.clone();
28    Ok(())
29}
30
31#[derive(Deserialize, Serialize, PartialEq, Eq)]
32#[serde(deny_unknown_fields)]
33struct Registration {
34    application: String,
35    namespace: String,
36    provider: String,
37    client_id: String,
38    callback: String,
39    revision: String,
40    client_secret_env: Option<String>,
41    #[serde(default)]
42    client_secret_ref: Option<String>,
43    #[serde(default)]
44    instagram_account_id: Option<String>,
45}
46
47struct Pending {
48    attempt: Option<PendingConnection>,
49    scope: ConnectionScope,
50    session: String,
51    previous: Option<ConnectionRecord>,
52    created: std::time::Instant,
53    provider: SocialProvider,
54}
55
56/// Keeps one-use consent state out of persistence, exports and logs; restart safely invalidates it.
57fn pending_registry() -> &'static std::sync::Mutex<std::collections::BTreeMap<String, Pending>> {
58    static PENDING: std::sync::OnceLock<
59        std::sync::Mutex<std::collections::BTreeMap<String, Pending>>,
60    > = std::sync::OnceLock::new();
61    PENDING.get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new()))
62}
63
64/// Consumes consent only in its initiating scope/session; another actor cannot invalidate it.
65fn take_pending(
66    key: &str,
67    scope: &ConnectionScope,
68    provider: SocialProvider,
69    session: &str,
70) -> Result<Pending, SocialError> {
71    let mut registry = pending_registry().lock().map_err(|_| SocialError::Busy)?;
72    let pending = registry
73        .get(key)
74        .ok_or(SocialError::AuthorizationRequired)?;
75    if &pending.scope != scope
76        || pending.provider != provider
77        || pending.session != session
78        || pending.created.elapsed() >= Duration::from_secs(300)
79    {
80        return Err(SocialError::Forbidden);
81    }
82    if pending.attempt.is_none() {
83        return Err(SocialError::AuthorizationRequired);
84    }
85    let pending = registry
86        .remove(key)
87        .ok_or(SocialError::AuthorizationRequired)?;
88    registry.insert(
89        key.into(),
90        Pending {
91            attempt: None,
92            scope: pending.scope.clone(),
93            session: pending.session.clone(),
94            previous: None,
95            created: pending.created,
96            provider: pending.provider,
97        },
98    );
99    Ok(pending)
100}
101
102/// Loads operator-only registrations from external protected storage, never from generated source.
103fn registration(
104    app: &AppRuntime,
105    scope: &ConnectionScope,
106    provider: SocialProvider,
107) -> Result<Option<Registration>, SocialError> {
108    if let Some(value) = settings::load(app, scope, provider)? {
109        return Ok(Some(value));
110    }
111    let operator_conf = if app.is_editor_preview {
112        OPERATOR_CONFIG
113            .read()
114            .map_err(|_| SocialError::Configuration)?
115            .clone()
116    } else {
117        app.runtime_conf_path.clone()
118    };
119    let Some(conf) = operator_conf.as_deref() else {
120        return Ok(None);
121    };
122    let Some(path) =
123        config::dynamic_config_lookup_raw(Path::new(conf), "SOCIAL_CONNECTIONS_CONFIG")
124            .filter(|path| !path.is_empty())
125    else {
126        return Ok(None);
127    };
128    let path = config::resolve_conf_relative_path(Path::new(conf), &path)
129        .ok_or(SocialError::Configuration)?;
130    let metadata = stdfs::symlink_metadata(&path).map_err(|_| SocialError::Configuration)?;
131    if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 65536 {
132        return Err(SocialError::Configuration);
133    }
134    #[cfg(unix)]
135    {
136        use std::os::unix::fs::PermissionsExt;
137        if metadata.permissions().mode() & 0o077 != 0 {
138            return Err(SocialError::Configuration);
139        }
140    }
141    let bytes = stdfs::read(&path).map_err(|_| SocialError::Configuration)?;
142    let registrations: Vec<Registration> =
143        serde_json::from_slice(&bytes).map_err(|_| SocialError::Configuration)?;
144    if registrations.len() > 32 {
145        return Err(SocialError::Configuration);
146    }
147    let mut selected = registrations.into_iter().filter(|row| {
148        row.application == scope.application()
149            && row.namespace == scope.namespace()
150            && row.provider == provider.key()
151    });
152    let value = selected.next();
153    if selected.next().is_some() {
154        return Err(SocialError::Configuration);
155    }
156    Ok(value)
157}
158
159/// Freezes an operator registration while leaving protocol endpoints and permissions provider-owned.
160fn client(app: &AppRuntime, registration: &Registration) -> Result<SocialClient, SocialError> {
161    let secret = registration
162        .client_secret_env
163        .as_ref()
164        .map(|name| {
165            if name.is_empty()
166                || name.len() > 128
167                || !name
168                    .bytes()
169                    .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
170            {
171                return Err(SocialError::Configuration);
172            }
173            SecretValue::new(std::env::var(name).map_err(|_| SocialError::Configuration)?)
174                .map_err(|_| SocialError::Configuration)
175        })
176        .transpose()?;
177    let stored = registration
178        .client_secret_ref
179        .as_ref()
180        .map(|key| {
181            use pharos_runtime_security::credentials::CredentialBackend;
182            let bytes = zeroize::Zeroizing::new(
183                vault(app)?
184                    .get_secret(key)
185                    .map_err(|_| SocialError::Configuration)?,
186            );
187            SecretValue::new(
188                std::str::from_utf8(&bytes)
189                    .map_err(|_| SocialError::Configuration)?
190                    .to_owned(),
191            )
192            .map_err(|_| SocialError::Configuration)
193        })
194        .transpose()?;
195    let mut client = SocialClient::new(
196        SocialProvider::parse(&registration.provider)?,
197        &registration.client_id,
198        &registration.callback,
199        &registration.revision,
200        stored.as_ref().or(secret.as_ref()),
201    )?;
202    if let Some(id) = &registration.instagram_account_id {
203        client = client.with_instagram_account(id)?;
204    }
205    Ok(client)
206}
207
208/// Restores the verified Instagram account for collection while leaving interactive consent free to select an account.
209fn collection_client(
210    app: &AppRuntime,
211    registration: &Registration,
212    scope: &ConnectionScope,
213) -> Result<SocialClient, SocialError> {
214    let client = client(app, registration)?;
215    if client.provider() != SocialProvider::Instagram {
216        return Ok(client);
217    }
218    let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
219    let vault = vault(app)?;
220    let repository = ConnectionRepository {
221        adapter: store.adapter.as_ref(),
222        config: &store.config,
223        schema: &store.schema,
224        vault: &vault,
225        guards: &[],
226    };
227    let row = repository
228        .load(scope, SocialProvider::Instagram)?
229        .ok_or(SocialError::AuthorizationRequired)?;
230    if registration
231        .instagram_account_id
232        .as_ref()
233        .is_some_and(|id| id != &row.account_id)
234    {
235        return Err(SocialError::Forbidden);
236    }
237    client.with_instagram_account(&row.account_id)
238}
239
240/// Combines storage authority with a host-owned deployment epoch; app names cannot revive old preview grants.
241fn connection_namespace(app: &AppRuntime, authority: &str) -> Result<String, SocialError> {
242    let epoch = match app.connection_namespace.as_deref() {
243        Some(epoch) => epoch,
244        None if app.is_editor_preview => return Err(SocialError::Configuration),
245        None => return Ok(authority.to_owned()),
246    };
247    ConnectionScope::new("social", epoch, 1)?;
248    let mut digest = Sha256::new();
249    digest.update(b"pharos.social.namespace.v1\0");
250    digest.update(authority.as_bytes());
251    digest.update(b"\0");
252    digest.update(epoch.as_bytes());
253    Ok(dynamic::hex_digest(&digest.finalize()))
254}
255
256/// Derives app/deployment scope exclusively from authenticated storage and host deployment identity.
257pub(super) fn scope(app: &AppRuntime, owner: i64) -> Result<ConnectionScope, SocialError> {
258    let authority = app_security_cache_scope(app).map_err(|_| SocialError::Configuration)?;
259    ConnectionScope::new(
260        app.app_id.as_deref().ok_or(SocialError::Configuration)?,
261        &connection_namespace(app, &authority)?,
262        owner,
263    )
264}
265
266/// Resolves an external vault beside runtime state, refusing app installation footprints.
267fn vault(app: &AppRuntime) -> Result<EncryptedFileCredentialBackend, SocialError> {
268    let parent = app
269        .state_path
270        .as_deref()
271        .and_then(|path| Path::new(path).parent())
272        .ok_or(SocialError::Configuration)?;
273    for root in [app.app_root.as_deref(), app.artifact_dir.as_deref()]
274        .into_iter()
275        .flatten()
276    {
277        if parent.starts_with(root) {
278            return Err(SocialError::Configuration);
279        }
280    }
281    let directory = parent.join("vault");
282    EncryptedFileCredentialBackend::open_or_create(
283        directory.join("social-connections.enc"),
284        &directory.join("social-root.key"),
285    )
286    .map_err(|_| SocialError::Transport)
287}
288
289/// Pins each request to the selected provider's exact reviewed protocol endpoints and one resource URL.
290fn provider_transport(
291    client: &SocialClient,
292    endpoint: Option<String>,
293) -> Result<pharos_runtime_integrations::oauth_http::NativeProviderHttp, SocialError> {
294    let mut endpoints = client.protocol_endpoints();
295    if let Some(endpoint) = endpoint {
296        if client.provider() == SocialProvider::Facebook {
297            let url = Url::parse(&endpoint).map_err(|_| SocialError::Configuration)?;
298            if let Some(target) = url
299                .path()
300                .strip_prefix("/v25.0/")
301                .and_then(|path| path.strip_suffix("/posts"))
302            {
303                endpoints.push((wire::Method::GET, client.page_token_endpoint(target)?));
304            }
305        }
306        endpoints.push((wire::Method::GET, endpoint));
307    }
308    pharos_runtime_integrations::oauth_http::NativeProviderHttp::for_endpoints(
309        &endpoints,
310        512 * 1024,
311    )
312    .map_err(|_| SocialError::Configuration)
313}
314
315/// Rechecks the actual durable session after network operations, including logout or owner deactivation.
316fn require_session(
317    store: &database::LogicalAppStore,
318    session: &str,
319    owner: i64,
320) -> Result<(), SocialError> {
321    let active = store
322        .adapter
323        .active_session(
324            &store.config,
325            &store.schema,
326            session,
327            SystemTime::now()
328                .duration_since(UNIX_EPOCH)
329                .map_err(|_| SocialError::Configuration)?
330                .as_secs(),
331        )
332        .map_err(|_| SocialError::Transport)?;
333    if active.is_none_or(|session| session.user_id != owner) {
334        return Err(SocialError::AuthorizationRequired);
335    }
336    let users = store
337        .adapter
338        .query(
339            &store.config,
340            &store.schema,
341            &pharos_runtime_database::operations::Query {
342                entity: "user_account".into(),
343                fields: vec!["id".into()],
344                filter: Some(pharos_runtime_database::operations::Predicate::All(vec![
345                    pharos_runtime_database::operations::Predicate::Equal(
346                        "id".into(),
347                        json!(owner),
348                    ),
349                    pharos_runtime_database::operations::Predicate::Equal(
350                        "is_active".into(),
351                        json!(true),
352                    ),
353                ])),
354                order: vec![],
355                limit: 1,
356                offset: 0,
357            },
358        )
359        .map_err(|_| SocialError::Transport)?;
360    if users.is_empty() {
361        return Err(SocialError::AuthorizationRequired);
362    }
363    Ok(())
364}
365
366/// Freezes one actual capability grant plus active-user/session assertions into the eventual database write.
367fn authority_guards(
368    app: &AppRuntime,
369    store: &database::LogicalAppStore,
370    owner: i64,
371    capability: &str,
372    session: Option<&str>,
373) -> Result<Vec<pharos_runtime_database::operations::Mutation>, SocialError> {
374    use pharos_runtime_database::operations::{Mutation, Predicate, Query};
375    let mut guards = vec![Mutation::RequireCount {
376        entity: "user_account".into(),
377        filter: Predicate::All(vec![
378            Predicate::Equal("id".into(), json!(owner)),
379            Predicate::Equal("is_active".into(), json!(true)),
380        ]),
381        minimum: 1,
382        maximum: Some(1),
383    }];
384    let ir = app
385        .application_ir
386        .as_ref()
387        .ok_or(SocialError::Configuration)?;
388    let roles = ir
389        .role_capability_edges
390        .iter()
391        .filter(|(_, granted)| granted.as_str() == capability)
392        .map(|(role, _)| json!(role.as_str()))
393        .collect::<Vec<_>>();
394    let mut granted = false;
395    for (entity, field) in [
396        ("user_profile", "account_type"),
397        ("store_membership", "role"),
398    ] {
399        if !store
400            .schema
401            .entities
402            .iter()
403            .any(|value| value.name == entity)
404        {
405            continue;
406        }
407        let filter = Predicate::All(vec![
408            Predicate::Equal("user_id".into(), json!(owner)),
409            Predicate::In(field.into(), roles.clone()),
410        ]);
411        let rows = store
412            .adapter
413            .query(
414                &store.config,
415                &store.schema,
416                &Query {
417                    entity: entity.into(),
418                    fields: vec!["user_id".into()],
419                    filter: Some(filter.clone()),
420                    order: vec![],
421                    limit: 1,
422                    offset: 0,
423                },
424            )
425            .map_err(|_| SocialError::Transport)?;
426        if !rows.is_empty() {
427            guards.push(Mutation::RequireCount {
428                entity: entity.into(),
429                filter,
430                minimum: 1,
431                maximum: None,
432            });
433            granted = true;
434            break;
435        }
436    }
437    if !granted {
438        return Err(SocialError::Forbidden);
439    }
440    if let Some(session) = session {
441        guards.push(Mutation::RequireCount {
442            entity: "auth_session".into(),
443            filter: Predicate::All(vec![
444                Predicate::Equal("user_id".into(), json!(owner)),
445                Predicate::Equal("session_token_hash".into(), json!(session)),
446                Predicate::IsNull("revoked_at".into()),
447                Predicate::GreaterThan("expires_at".into(), json!(current_timestamp_utc())),
448            ]),
449            minimum: 1,
450            maximum: Some(1),
451        });
452    }
453    Ok(guards)
454}
455
456/// Routes bounded owner operations before app handlers; capability comes from the app's declared policies.
457pub(super) fn response(
458    app: &AppRuntime,
459    request: &http::HttpRequest,
460    relative: &str,
461) -> Option<Vec<u8>> {
462    response_at(app, request, relative, None)
463}
464
465/// Accepts a host-selected callback route for an isolated preview without trusting browser form values.
466pub(super) fn response_at(
467    app: &AppRuntime,
468    request: &http::HttpRequest,
469    relative: &str,
470    callback_path: Option<&str>,
471) -> Option<Vec<u8>> {
472    let path = relative.split('?').next().unwrap_or("");
473    if !path.starts_with(PREFIX) {
474        return None;
475    }
476    Some(match handle(app, request, relative, path, callback_path) {
477        Ok(response) => response,
478        Err(error) => failure(error, &request.method),
479    })
480}
481
482/// Returns only fixed service diagnostics, never provider bodies, callback parameters or token references.
483fn failure(error: SocialError, method: &str) -> Vec<u8> {
484    let status = match error {
485        SocialError::Forbidden => 403,
486        SocialError::AuthorizationRequired => 401,
487        SocialError::Busy => 409,
488        SocialError::RateLimited => 429,
489        SocialError::Capacity => 413,
490        SocialError::Configuration | SocialError::Transport => 503,
491        _ => 422,
492    };
493    http_json_response(
494        status,
495        http_reason_phrase(status),
496        &json!({"error":error.code(),"message":error.to_string()}).to_string(),
497        method,
498    )
499}
500
501/// Nonsecret website state composed into the same app-owned status renderer as optional API grants.
502struct BrowserStatus {
503    available: bool,
504    pending: bool,
505}
506
507/// Renders only a declared app-owned component; the framework supplies escaped nonsecret values.
508#[allow(clippy::too_many_arguments)]
509fn status_response(
510    app: &AppRuntime,
511    request: &http::HttpRequest,
512    declaration: &Value,
513    registered: Option<&Registration>,
514    provider: SocialProvider,
515    scope: &ConnectionScope,
516    row: Option<ConnectionRecord>,
517    session: &str,
518    callback_path: Option<&str>,
519    browser: Option<BrowserStatus>,
520) -> Result<Vec<u8>, SocialError> {
521    if let Some(template) = declaration.get("status_template").and_then(Value::as_str) {
522        if template.len() > 512
523            || !template.starts_with("templates/")
524            || !template.ends_with(".html")
525            || Path::new(template)
526                .components()
527                .any(|component| !matches!(component, std::path::Component::Normal(_)))
528        {
529            return Err(SocialError::Configuration);
530        }
531        let is_browser = browser.is_some();
532        let configured = browser
533            .as_ref()
534            .map_or(registered.is_some(), |state| state.available);
535        let connected = row.as_ref().is_some_and(|row| row.status == "connected");
536        let saved_browser = is_browser
537            && row
538                .as_ref()
539                .is_some_and(|row| !row.credential_ref.is_empty());
540        let pending = browser.as_ref().map_or_else(
541            || consent_ui::pending(scope, provider, session),
542            |state| state.pending,
543        );
544        let disconnectable = connected
545            || row.as_ref().is_some_and(|row| {
546                matches!(row.status.as_str(), "revoking" | "reauthorization_required")
547            })
548            || is_browser && (saved_browser || pending);
549        let feedback = consent_ui::outcome(scope, provider, session);
550        let setup_allowed = !is_browser && settings::can_setup(app, declaration, scope.owner())?;
551        let callback = if setup_allowed && !configured {
552            Some(settings::callback_url(
553                app,
554                request,
555                provider,
556                callback_path,
557            )?)
558        } else {
559            None
560        };
561        let mut params = json!({
562                "social_api_hidden":if is_browser{"hidden"}else{""},
563                "social_browser_unavailable_hidden":if is_browser && !configured{""}else{"hidden"},
564                "social_browser_finish_hidden":"hidden",
565                "social_auto_finish_hidden":if is_browser && pending{""}else{"hidden"},
566                "social_auto_trigger":if is_browser && pending{"every 2s"}else{"none"},
567                "social_disconnect_hidden":if disconnectable {""}else{"hidden"},
568                "social_connect_hidden":if pending || disconnectable {"hidden"}else{""},
569                "social_secret_required":if provider==SocialProvider::X {""}else{"required"},
570                "social_setup_hidden":if setup_allowed && !configured {""}else{"hidden"},
571                "social_setup_url":app_scope_path(app,&format!("/__pharos/social/{}/setup",provider.key())),
572                "social_callback_url":callback,
573                "social_connection_mode":if is_browser{"browser"}else{"oauth"}, "social_extension_hidden":"hidden", "social_browser_hidden":if is_browser{""}else{"hidden"}, "social_browser_failed_hidden":if is_browser && feedback==Some(consent_ui::Outcome::Failed){""}else{"hidden"}, "social_finish_hidden":"hidden", "social_browser_pending_hidden":"hidden", "social_connect_target":"_self", "social_finish_url":app_scope_path(app,&format!("/__pharos/social/{}/finish",provider.key())),
574                "social_pending_hidden":if pending{""}else{"hidden"},
575                "social_cancelled_hidden":if feedback==Some(consent_ui::Outcome::Cancelled){""}else{"hidden"},
576                "social_failed_hidden":if feedback==Some(consent_ui::Outcome::Failed){""}else{"hidden"},
577                "social_selection_hidden":if feedback==Some(consent_ui::Outcome::SelectionRequired){""}else{"hidden"},
578                "social_facebook_hidden":if provider==SocialProvider::Facebook{""}else{"hidden"},
579                "social_instagram_hidden":if provider==SocialProvider::Instagram{""}else{"hidden"},
580                "social_x_hidden":if provider==SocialProvider::X{""}else{"hidden"},
581                "social_runtime_status_hidden":if configured && !pending{""}else{"hidden"},
582                "social_cancel_url":app_scope_path(app,&format!("/__pharos/social/{}/cancel",provider.key())),
583                "social_status":row.as_ref().map(|row|row.status.as_str()).unwrap_or("disconnected"),
584                "social_username":row.as_ref().map(|row|row.username.as_str()).unwrap_or(""),
585                "social_namespace":scope.namespace(),
586                "social_provider":provider.key(), "social_provider_label":provider.label(),
587                "social_unavailable_hidden":if configured || is_browser {"hidden"}else{""},
588                "social_available_hidden":if configured && !is_browser {""}else{"hidden"},
589                "social_connect_url":app_scope_path(app,&format!("/__pharos/social/{}/connect",provider.key())),
590                "social_disconnect_url":app_scope_path(app,&format!("/__pharos/social/{}/disconnect",provider.key())),
591                "social_connect_disabled":if configured && !pending {""} else {"disabled"},
592                "social_disconnect_disabled":if configured && disconnectable {""} else {"disabled"},
593                "csrf_token":dynamic::csrf_token_for_response(request.cookie.as_deref()),
594        });
595        for (key, candidate) in [
596            ("youtube", SocialProvider::Youtube),
597            ("soundcloud", SocialProvider::Soundcloud),
598            ("spotify", SocialProvider::Spotify),
599        ] {
600            params[format!("social_{key}_hidden")] =
601                json!(if provider == candidate { "" } else { "hidden" });
602        }
603        params["social_reconnect_hidden"] = json!(if is_browser { "" } else { "hidden" });
604        params["social_reconnect_disabled"] = json!(if saved_browser && !pending && configured {
605            ""
606        } else {
607            "disabled"
608        });
609        return Ok(app_fragment_response(
610            app,
611            template,
612            params,
613            &request.method,
614        ));
615    }
616    Ok(http_json_response(200,"OK",&json!({"provider":provider.key(),"namespace":scope.namespace(),"configured":registered.is_some(),"connection":row}).to_string(),&request.method))
617}
618
619/// Accepts only bounded same-app return paths, before any external grant or credential is changed.
620fn return_destination(declaration: &Value) -> Result<&str, SocialError> {
621    let destination = declaration
622        .get("return_path")
623        .and_then(Value::as_str)
624        .unwrap_or("/__pharos/social/x/status");
625    if destination.len() > 2048
626        || !destination.starts_with('/')
627        || destination.starts_with("//")
628        || destination.contains(['\\', '?', '#', ' '])
629        || destination.chars().any(char::is_control)
630    {
631        return Err(SocialError::Configuration);
632    }
633    Ok(destination)
634}
635
636/// Accepts only declared API or website transport; malformed modes never fall back to another authority.
637fn browser_mode(declaration: &Value) -> Result<bool, SocialError> {
638    match declaration.get("connection_mode") {
639        None => Ok(false),
640        Some(Value::String(mode)) if mode == "api" => Ok(false),
641        Some(Value::String(mode)) if mode == "browser" => Ok(true),
642        _ => Err(SocialError::Configuration),
643    }
644}
645
646/// Applies owner/session/CSRF gates and dispatches the declared external-account transport.
647fn handle(
648    app: &AppRuntime,
649    request: &http::HttpRequest,
650    relative: &str,
651    path: &str,
652    callback_path: Option<&str>,
653) -> Result<Vec<u8>, SocialError> {
654    let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
655        .ok_or(SocialError::Forbidden)?;
656    if declaration.get("enabled").and_then(Value::as_bool) != Some(true) {
657        return Err(SocialError::Forbidden);
658    }
659    let owner = active_session_user_id(app, request.cookie.as_deref())
660        .map_err(|_| SocialError::Transport)?;
661    if owner <= 0 {
662        return Err(SocialError::AuthorizationRequired);
663    }
664    let capability = declaration
665        .get("owner_capability")
666        .and_then(Value::as_str)
667        .filter(|value| {
668            !matches!(
669                *value,
670                "public" | "customer" | "account_login" | "account_logout" | "authenticated_user"
671            )
672        })
673        .ok_or(SocialError::Configuration)?;
674    if !actor_has_route_policy(app, owner, capability).map_err(|_| SocialError::Transport)? {
675        return Err(SocialError::Forbidden);
676    }
677    let (provider, operation) = path
678        .strip_prefix(PREFIX)
679        .and_then(|p| p.split_once('/'))
680        .ok_or(SocialError::Configuration)?;
681    let provider = SocialProvider::parse(provider)?;
682    if !matches!(
683        (request.method.as_str(), operation),
684        ("GET", "status" | "callback")
685            | (
686                "POST",
687                "connect" | "cancel" | "disconnect" | "setup" | "finish"
688            )
689    ) {
690        return Err(SocialError::Forbidden);
691    }
692    if request.method == "POST"
693        && (!trusted_browser_origin(request)
694            || !dynamic::csrf_request_valid(
695                request,
696                dynamic::request_body_text(request).as_deref(),
697            ))
698    {
699        return Err(SocialError::Forbidden);
700    }
701    let scope = scope(app, owner)?;
702    let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
703    let session = request
704        .cookie
705        .as_deref()
706        .and_then(|cookie| dynamic::dynamic_cookie_value(cookie, "pharos_session"))
707        .and_then(|token| native_session_token_hash(app, &token))
708        .ok_or(SocialError::AuthorizationRequired)?;
709    require_session(&store, &session, owner)?;
710    if browser_mode(&declaration)? {
711        #[cfg(unix)]
712        return browser::handle(
713            app,
714            request,
715            &declaration,
716            provider,
717            operation,
718            &scope,
719            &store,
720            &session,
721            capability,
722        );
723        #[cfg(not(unix))]
724        return Err(SocialError::UnsupportedProvider);
725    }
726    if operation == "finish" {
727        return Err(SocialError::Forbidden);
728    }
729    if operation == "setup" {
730        pharos_runtime_security::local_role_cache_revoke_actor(scope.namespace(), owner);
731        if !settings::can_setup(app, &declaration, owner)? {
732            return Err(SocialError::Forbidden);
733        }
734        if request.body_len > 16384
735            || request.body.len() > 16384
736            || request.body_file_path.is_some()
737        {
738            return Err(SocialError::Capacity);
739        }
740        let client_id = dynamic::request_form_value(request, "client_id").unwrap_or_default();
741        let secret = zeroize::Zeroizing::new(
742            dynamic::request_form_value(request, "client_secret").unwrap_or_default(),
743        );
744        let callback = settings::callback_url(app, request, provider, callback_path)?;
745        let destination = return_destination(&declaration)?;
746        settings::save(app, &scope, provider, client_id.trim(), &secret, &callback)?;
747        return Ok(http_route_redirect_for_request(
748            request,
749            &app_scope_path(app, destination),
750        ));
751    }
752    if operation == "cancel" {
753        consent_ui::cancel(&scope, provider, &session)?;
754        return Ok(http_route_redirect_for_request(
755            request,
756            &app_scope_path(app, return_destination(&declaration)?),
757        ));
758    }
759    let registered = registration(app, &scope, provider)?;
760    if operation == "status" {
761        let row = if store
762            .schema
763            .entities
764            .iter()
765            .any(|entity| entity.name == "social_connection")
766        {
767            let vault = vault(app)?;
768            ConnectionRepository {
769                adapter: store.adapter.as_ref(),
770                config: &store.config,
771                schema: &store.schema,
772                vault: &vault,
773                guards: &[],
774            }
775            .load(&scope, provider)?
776        } else {
777            None
778        };
779        if relative
780            .split_once('?')
781            .and_then(|(_, query)| json_commands::request_form_value(query, "format"))
782            .as_deref()
783            == Some("json")
784        {
785            return Ok(http_json_response(200,"OK",&json!({"provider":provider.key(),"namespace":scope.namespace(),"mode":"oauth","setup_scope":"application_deployment","connection_scope":"owner","setup_allowed":settings::can_setup(app,&declaration,owner)?,"configured":registered.is_some(),"authorization_pending":consent_ui::pending(&scope,provider,&session),"connection":row}).to_string(),&request.method));
786        }
787        return status_response(
788            app,
789            request,
790            &declaration,
791            registered.as_ref(),
792            provider,
793            &scope,
794            row,
795            &session,
796            callback_path,
797            None,
798        );
799    }
800    let destination = return_destination(&declaration)?;
801    let registered = registered.ok_or(SocialError::Configuration)?;
802    let client = client(app, &registered)?;
803    let vault = vault(app)?;
804    let guards = authority_guards(app, &store, owner, capability, Some(&session))?;
805    let repository = ConnectionRepository {
806        adapter: store.adapter.as_ref(),
807        config: &store.config,
808        schema: &store.schema,
809        vault: &vault,
810        guards: &guards,
811    };
812    if operation == "connect" {
813        let pending = client.begin(scope.clone())?;
814        let url =
815            Url::parse(pending.authorization_url()).map_err(|_| SocialError::Configuration)?;
816        let state = url
817            .query_pairs()
818            .find(|(key, _)| key == "state")
819            .ok_or(SocialError::Configuration)?
820            .1
821            .into_owned();
822        let destination = pending.authorization_url().to_owned();
823        let previous = repository.load(&scope, provider)?;
824        let mut registry = pending_registry().lock().map_err(|_| SocialError::Busy)?;
825        registry.retain(|_, pending| {
826            pending.created.elapsed() < Duration::from_secs(300)
827                && (pending.scope != scope || pending.provider != provider)
828        });
829        if registry.len() >= 128 {
830            return Err(SocialError::Capacity);
831        }
832        consent_ui::record(&scope, provider, &session, None);
833        registry.insert(
834            dynamic::hex_digest(&Sha256::digest(state.as_bytes())),
835            Pending {
836                attempt: Some(pending),
837                scope,
838                session,
839                previous,
840                provider,
841                created: std::time::Instant::now(),
842            },
843        );
844        return Ok(http_route_redirect_for_request(request, &destination));
845    }
846    let runtime = tokio::runtime::Builder::new_current_thread()
847        .enable_all()
848        .build()
849        .map_err(|_| SocialError::Transport)?;
850    let http = provider_transport(&client, None)?;
851    if operation == "callback" {
852        let query = relative
853            .split_once('?')
854            .map(|(_, query)| query)
855            .ok_or(SocialError::AuthorizationRequired)?;
856        let callback =
857            CallbackParameters::parse(query).map_err(|_| SocialError::AuthorizationRequired)?;
858        let state = json_commands::request_form_value(query, "state")
859            .ok_or(SocialError::AuthorizationRequired)?;
860        let key = dynamic::hex_digest(&Sha256::digest(state.as_bytes()));
861        let pending = take_pending(&key, &scope, provider, &session)?;
862        let result = runtime.block_on(client.finish(
863            &http,
864            &Cancellation::default(),
865            &scope,
866            pending.attempt.ok_or(SocialError::AuthorizationRequired)?,
867            callback,
868        ));
869        let account = match result {
870            Ok(account) => account,
871            Err(error) => {
872                let mut claimed = consent_ui::claim_guard(&key, &scope, provider, &session)?;
873                claimed.remove(&key);
874                drop(claimed);
875                let outcome = if error == SocialError::ResourceSelectionRequired {
876                    consent_ui::Outcome::SelectionRequired
877                } else if json_commands::request_form_value(query, "error").as_deref()
878                    == Some("access_denied")
879                {
880                    consent_ui::Outcome::Cancelled
881                } else {
882                    consent_ui::Outcome::Failed
883                };
884                consent_ui::record(&scope, provider, &session, Some(outcome));
885                return Ok(http_route_redirect_for_request(
886                    request,
887                    &app_scope_path(app, destination),
888                ));
889            }
890        };
891        require_session(&store, &session, owner)?;
892        pharos_runtime_security::local_role_cache_revoke_actor(scope.namespace(), owner);
893        if !actor_has_route_policy(app, owner, capability).map_err(|_| SocialError::Transport)? {
894            return Err(SocialError::Forbidden);
895        }
896        if registration(app, &scope, provider)?.as_ref() != Some(&registered) {
897            return Err(SocialError::Forbidden);
898        }
899        let mut claimed = consent_ui::claim_guard(&key, &scope, provider, &session)?;
900        let promoted = repository.promote(
901            &client,
902            &scope,
903            provider,
904            &registered.revision,
905            pending.previous.as_ref(),
906            &account,
907        );
908        claimed.remove(&key);
909        drop(claimed);
910        if promoted.is_err() {
911            consent_ui::record(
912                &scope,
913                provider,
914                &session,
915                Some(consent_ui::Outcome::Failed),
916            );
917            return Ok(http_route_redirect_for_request(
918                request,
919                &app_scope_path(app, destination),
920            ));
921        }
922        consent_ui::record(&scope, provider, &session, None);
923        let now = SystemTime::now()
924            .duration_since(UNIX_EPOCH)
925            .map_err(|_| SocialError::Configuration)?
926            .as_secs() as i64;
927        repository.resume_collections(&scope, provider, now)?;
928    } else {
929        let row = repository
930            .load(&scope, provider)?
931            .ok_or(SocialError::AuthorizationRequired)?;
932        let account = if row.status == "revoking" {
933            use pharos_runtime_security::credentials::CredentialBackend;
934            let bytes = zeroize::Zeroizing::new(
935                vault
936                    .get_secret(&row.credential_ref)
937                    .map_err(|_| SocialError::Transport)?,
938            );
939            client.restore_from_vault(&scope, &bytes)?
940        } else {
941            repository.account(&client, &scope, &row, &registered.revision)?
942        };
943        let revoked = if row.status == "revoking" {
944            row
945        } else {
946            repository.disconnect(&row)?
947        };
948        repository.cancel_collections(&scope, provider)?;
949        runtime.block_on(client.revoke(&http, &Cancellation::default(), &scope, account))?;
950        repository.finish_disconnect(&revoked)?;
951        consent_ui::record(&scope, provider, &session, None);
952        pending_registry()
953            .lock()
954            .map_err(|_| SocialError::Busy)?
955            .retain(|_, pending| pending.scope != scope || pending.provider != provider);
956    }
957    Ok(http_route_redirect_for_request(
958        request,
959        &app_scope_path(app, destination),
960    ))
961}
962
963#[cfg(test)]
964mod tests;
965
966/// Verifies retained background authority without requiring the owner to leave a browser session open.
967fn collection_context(
968    app: &AppRuntime,
969    provider: SocialProvider,
970) -> Result<(ConnectionScope, database::LogicalAppStore, Registration), SocialError> {
971    let (scope, store) = collection_authority(app, None)?;
972    let registration =
973        registration(app, &scope, provider)?.ok_or(SocialError::AuthorizationRequired)?;
974    Ok((scope, store, registration))
975}
976
977/// Rechecks durable active-owner authority independently of the selected API or website transport.
978fn collection_authority(
979    app: &AppRuntime,
980    selected_owner: Option<i64>,
981) -> Result<(ConnectionScope, database::LogicalAppStore), SocialError> {
982    use pharos_runtime_database::operations::{Predicate, Query};
983    let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
984        .ok_or(SocialError::AuthorizationRequired)?;
985    if declaration.get("enabled").and_then(Value::as_bool) != Some(true) {
986        return Err(SocialError::AuthorizationRequired);
987    }
988    let owner = selected_owner
989        .or_else(|| declaration.get("collection_owner").and_then(Value::as_i64))
990        .ok_or(SocialError::AuthorizationRequired)?;
991    let scope = scope(app, owner)?;
992    let capability = declaration
993        .get("owner_capability")
994        .and_then(Value::as_str)
995        .filter(|value| {
996            !matches!(
997                *value,
998                "public" | "customer" | "account_login" | "account_logout" | "authenticated_user"
999            )
1000        })
1001        .ok_or(SocialError::Configuration)?;
1002    pharos_runtime_security::local_role_cache_revoke_actor(scope.namespace(), owner);
1003    if !actor_has_route_policy(app, owner, capability).map_err(|_| SocialError::Transport)? {
1004        return Err(SocialError::Forbidden);
1005    }
1006    let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
1007    let users = store
1008        .adapter
1009        .query(
1010            &store.config,
1011            &store.schema,
1012            &Query {
1013                entity: "user_account".into(),
1014                fields: vec!["id".into()],
1015                filter: Some(Predicate::All(vec![
1016                    Predicate::Equal("id".into(), json!(owner)),
1017                    Predicate::Equal("is_active".into(), json!(true)),
1018                ])),
1019                order: vec![],
1020                limit: 1,
1021                offset: 0,
1022            },
1023        )
1024        .map_err(|_| SocialError::Transport)?;
1025    if users.is_empty() {
1026        return Err(SocialError::Forbidden);
1027    }
1028    Ok((scope, store))
1029}
1030
1031/// Refreshes near-expiry authority under the durable lease and immutable revision promotion.
1032fn current_account(
1033    repository: &ConnectionRepository<'_>,
1034    client: &SocialClient,
1035    scope: &ConnectionScope,
1036    registration: &Registration,
1037) -> Result<
1038    (
1039        ConnectionRecord,
1040        pharos_runtime_social::connection::ConnectedAccount,
1041    ),
1042    SocialError,
1043> {
1044    let row = repository
1045        .load(scope, client.provider())?
1046        .ok_or(SocialError::AuthorizationRequired)?;
1047    let account = repository.account(client, scope, &row, &registration.revision)?;
1048    let now = SystemTime::now()
1049        .duration_since(UNIX_EPOCH)
1050        .map_err(|_| SocialError::Configuration)?
1051        .as_secs() as i64;
1052    if row.expires > now + 60 {
1053        return Ok((row, account));
1054    }
1055    let leased = repository.lease_refresh(&row, now)?;
1056    let runtime = tokio::runtime::Builder::new_current_thread()
1057        .enable_all()
1058        .build()
1059        .map_err(|_| SocialError::Transport)?;
1060    let account = runtime.block_on(client.refresh(
1061        &provider_transport(client, None)?,
1062        &Cancellation::default(),
1063        scope,
1064        &account,
1065    ))?;
1066    let updated = repository.promote(
1067        client,
1068        scope,
1069        client.provider(),
1070        &registration.revision,
1071        Some(&leased),
1072        &account,
1073    )?;
1074    Ok((updated, account))
1075}
1076
1077/// Processes one due social page independently of domain tables, preserving cancellation and restart leases.
1078pub(super) fn process_one(app: &AppRuntime) {
1079    #[cfg(unix)]
1080    browser::maintenance();
1081    if dynamic::dynamic_contract_doc(app, "social_connections_contract")
1082        .as_ref()
1083        .map(browser_mode)
1084        .transpose()
1085        .unwrap_or(Some(true))
1086        .unwrap_or(false)
1087    {
1088        return;
1089    }
1090
1091    for provider in [
1092        SocialProvider::Facebook,
1093        SocialProvider::Instagram,
1094        SocialProvider::X,
1095    ] {
1096        process_provider(app, provider);
1097    }
1098}
1099
1100/// Processes a bounded page independently for each configured provider.
1101fn process_provider(app: &AppRuntime, provider: SocialProvider) {
1102    if super::runtime_shutdown_requested() {
1103        return;
1104    }
1105    let Ok((scope, store, registration)) = collection_context(app, provider) else {
1106        return;
1107    };
1108    let Ok(vault) = vault(app) else {
1109        return;
1110    };
1111    let Some(capability) = dynamic::dynamic_contract_doc(app, "social_connections_contract")
1112        .and_then(|value| {
1113            value
1114                .get("owner_capability")
1115                .and_then(Value::as_str)
1116                .map(str::to_owned)
1117        })
1118    else {
1119        return;
1120    };
1121    let Ok(guards) = authority_guards(app, &store, scope.owner(), &capability, None) else {
1122        return;
1123    };
1124    let repository = ConnectionRepository {
1125        adapter: store.adapter.as_ref(),
1126        config: &store.config,
1127        schema: &store.schema,
1128        vault: &vault,
1129        guards: &guards,
1130    };
1131    let Ok(client) = collection_client(app, &registration, &scope) else {
1132        return;
1133    };
1134    let now = SystemTime::now()
1135        .duration_since(UNIX_EPOCH)
1136        .map(|value| value.as_secs() as i64)
1137        .unwrap_or(0);
1138    let Ok(Some(lease)) = repository.claim_collection(&scope, provider, now) else {
1139        return;
1140    };
1141    let result = (|| {
1142        let (row, account) = current_account(&repository, &client, &scope, &registration)?;
1143        let endpoint =
1144            client.collection_endpoint(&pharos_runtime_social::connection::PostQuery {
1145                account_id: &lease.target,
1146                since_id: (!lease.since_id.is_empty()).then_some(lease.since_id.as_str()),
1147                page_token: (!lease.page_token.is_empty()).then_some(lease.page_token.as_str()),
1148            })?;
1149        let http = provider_transport(&client, Some(endpoint))?;
1150        let runtime = tokio::runtime::Builder::new_current_thread()
1151            .enable_all()
1152            .build()
1153            .map_err(|_| SocialError::Transport)?;
1154        let page = runtime.block_on(repository.collect_page(
1155            &client,
1156            &http,
1157            &Cancellation::default(),
1158            &scope,
1159            &account,
1160            &lease,
1161        ))?;
1162        if super::runtime_shutdown_requested() {
1163            return Err(SocialError::Busy);
1164        }
1165        collection_context(app, provider)?;
1166        repository.commit_page(
1167            &scope,
1168            provider,
1169            &row,
1170            &lease,
1171            &page,
1172            SystemTime::now()
1173                .duration_since(UNIX_EPOCH)
1174                .map_err(|_| SocialError::Configuration)?
1175                .as_secs() as i64,
1176        )
1177    })();
1178    if let Err(error) = result {
1179        let _ = repository.fail_collection(&lease, error, now);
1180    }
1181}
1182
1183/// Retained inert observations plus guards for atomic acceptance by a domain consumer.
1184pub(super) struct ObservationBatch {
1185    pub links: Vec<String>,
1186    pub evidence: Vec<Value>,
1187    pub unverified_posts: usize,
1188    pub verified_posts: usize,
1189    pub guards: Vec<pharos_runtime_database::operations::Mutation>,
1190}
1191
1192/// Resolves an explicit social profile and schedules its retained observations for an authorized consumer.
1193pub(super) fn observations_for_profile(
1194    app: &AppRuntime,
1195    profile: &str,
1196    interval: i64,
1197) -> Result<ObservationBatch, SocialError> {
1198    if dynamic::dynamic_contract_doc(app, "social_connections_contract")
1199        .as_ref()
1200        .map(browser_mode)
1201        .transpose()?
1202        .unwrap_or(false)
1203    {
1204        #[cfg(unix)]
1205        return browser::observations(app, profile);
1206        #[cfg(not(unix))]
1207        return Err(SocialError::UnsupportedProvider);
1208    }
1209    let parsed = Url::parse(profile).map_err(|_| SocialError::Configuration)?;
1210    let provider = SocialProvider::from_url(&parsed).ok_or(SocialError::UnsupportedProvider)?;
1211    let (scope, store, registration) = collection_context(app, provider)?;
1212    let client = collection_client(app, &registration, &scope)?;
1213    let endpoint = client.resource_endpoint(profile)?;
1214    let vault = vault(app)?;
1215    let capability = dynamic::dynamic_contract_doc(app, "social_connections_contract")
1216        .and_then(|value| {
1217            value
1218                .get("owner_capability")
1219                .and_then(Value::as_str)
1220                .map(str::to_owned)
1221        })
1222        .ok_or(SocialError::Configuration)?;
1223    let guards = authority_guards(app, &store, scope.owner(), &capability, None)?;
1224    let repository = ConnectionRepository {
1225        adapter: store.adapter.as_ref(),
1226        config: &store.config,
1227        schema: &store.schema,
1228        vault: &vault,
1229        guards: &guards,
1230    };
1231
1232    let (connection, account) = current_account(&repository, &client, &scope, &registration)?;
1233    let runtime = tokio::runtime::Builder::new_current_thread()
1234        .enable_all()
1235        .build()
1236        .map_err(|_| SocialError::Transport)?;
1237    let target = runtime.block_on(client.resolve_profile(
1238        &provider_transport(&client, Some(endpoint))?,
1239        &Cancellation::default(),
1240        &scope,
1241        &account,
1242        profile,
1243    ))?;
1244    let now = SystemTime::now()
1245        .duration_since(UNIX_EPOCH)
1246        .map_err(|_| SocialError::Configuration)?
1247        .as_secs() as i64;
1248    repository.schedule(&scope, provider, &target, interval, now)?;
1249    repository.collection_outcome(&scope, provider, &target)?;
1250    if !repository.collection_complete(&scope, provider, &target, now)? {
1251        return Err(SocialError::Busy);
1252    }
1253    let mut links = std::collections::BTreeSet::new();
1254    let mut complete = false;
1255    for offset in (0..10000).step_by(100) {
1256        let observations = repository.observations(&scope, provider, &target, offset)?;
1257        complete = observations.len() < 100;
1258        for observation in observations {
1259            let encoded = observation
1260                .get("links")
1261                .and_then(Value::as_str)
1262                .ok_or(SocialError::InvalidResponse)?;
1263            let urls: Vec<String> =
1264                serde_json::from_str(encoded).map_err(|_| SocialError::InvalidResponse)?;
1265            for url in urls {
1266                links.insert(url);
1267                if links.len() > 1000 {
1268                    return Err(SocialError::Capacity);
1269                }
1270            }
1271        }
1272        if complete {
1273            break;
1274        }
1275    }
1276    if !complete {
1277        return Err(SocialError::Capacity);
1278    }
1279    let mut guards = guards.clone();
1280    use pharos_runtime_database::operations::{Mutation, Predicate};
1281    guards.push(Mutation::RequireCount {
1282        entity: "social_connection".into(),
1283        filter: Predicate::All(vec![
1284            pharos_runtime_social::repository::scope_filter(&scope, provider),
1285            Predicate::Equal("revision".into(), json!(connection.revision)),
1286            Predicate::Equal("status".into(), json!("connected")),
1287        ]),
1288        minimum: 1,
1289        maximum: Some(1),
1290    });
1291    Ok(ObservationBatch {
1292        links: links.into_iter().collect(),
1293        evidence: Vec::new(),
1294        unverified_posts: 0,
1295        verified_posts: 0,
1296        guards,
1297    })
1298}
1299
1300/// Flushes explicitly authorized browser sessions before the native host exits; API grants are unaffected.
1301#[cfg(unix)]
1302pub(super) fn shutdown_browsers() {
1303    browser::shutdown();
1304}