Skip to main content

pharos_native/runtime/social_connections/
browser.rs

1//! Native desktop website authorization with persistent profiles and encrypted owner session checkpoints.
2use super::*;
3use pharos_runtime_integrations::browser::{Browser, BrowserError};
4use pharos_runtime_social::browser as sessions;
5use std::sync::{Arc, Mutex, OnceLock};
6
7struct PendingBrowser {
8    scope: ConnectionScope,
9    provider: SocialProvider,
10    session: String,
11    browser: Browser,
12    target: String,
13    previous: Option<ConnectionRecord>,
14    created: std::time::Instant,
15}
16type PendingMap = std::collections::BTreeMap<String, Arc<Mutex<PendingBrowser>>>;
17/// Keeps private browser handles out of metadata, exports and HTTP responses.
18fn registry() -> &'static Mutex<PendingMap> {
19    static REGISTRY: OnceLock<Mutex<PendingMap>> = OnceLock::new();
20    REGISTRY.get_or_init(|| Mutex::new(PendingMap::new()))
21}
22/// Serializes owner UI transitions without holding the global registry lock during browser IO.
23fn operation_lock(
24    scope: &ConnectionScope,
25    provider: SocialProvider,
26) -> Result<Arc<Mutex<()>>, SocialError> {
27    static LOCKS: OnceLock<Mutex<std::collections::BTreeMap<String, Arc<Mutex<()>>>>> =
28        OnceLock::new();
29    let mut locks = LOCKS
30        .get_or_init(|| Mutex::new(std::collections::BTreeMap::new()))
31        .lock()
32        .map_err(|_| SocialError::Busy)?;
33    locks.retain(|_, value| Arc::strong_count(value) > 1);
34    if locks.len() >= 256 {
35        return Err(SocialError::Busy);
36    }
37    Ok(locks
38        .entry(reference(scope, provider))
39        .or_insert_with(|| Arc::new(Mutex::new(())))
40        .clone())
41}
42/// Derives a stable persistent profile identity without trusting form-supplied paths or owner identifiers.
43fn reference(scope: &ConnectionScope, provider: SocialProvider) -> String {
44    dynamic::hex_digest(&Sha256::digest(
45        format!(
46            "{}\0{}\0{}\0{}",
47            scope.application(),
48            scope.namespace(),
49            scope.owner(),
50            provider.key()
51        )
52        .as_bytes(),
53    ))
54}
55/// Reads browser enablement only from the trusted serving configuration, never an imported app source.
56fn executable(app: &AppRuntime) -> Result<PathBuf, SocialError> {
57    let conf = if app.is_editor_preview {
58        OPERATOR_CONFIG
59            .read()
60            .map_err(|_| SocialError::Configuration)?
61            .clone()
62    } else {
63        app.runtime_conf_path.clone()
64    }
65    .ok_or(SocialError::Configuration)?;
66    let conf = Path::new(&conf);
67    if config::dynamic_config_lookup_raw(conf, "SOCIAL_BROWSER_MODE").as_deref() != Some("local") {
68        return Err(SocialError::UnsupportedProvider);
69    }
70    let path = config::dynamic_config_lookup_raw(conf, "SOCIAL_BROWSER_EXECUTABLE")
71        .filter(|value| !value.is_empty())
72        .map(PathBuf::from)
73        .or_else(|| {
74            [
75                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
76                "/usr/bin/chromium",
77                "/usr/bin/google-chrome",
78            ]
79            .iter()
80            .map(PathBuf::from)
81            .find(|path| path.is_file())
82        })
83        .ok_or(SocialError::Configuration)?;
84    if !path.is_absolute() || !path.is_file() {
85        return Err(SocialError::Configuration);
86    }
87    Ok(path)
88}
89/// Resolves an external persistent browser area; installed app replacement cannot remove login state.
90fn profile(
91    app: &AppRuntime,
92    scope: &ConnectionScope,
93    provider: SocialProvider,
94) -> Result<PathBuf, SocialError> {
95    let root = app
96        .state_path
97        .as_deref()
98        .and_then(|path| Path::new(path).parent())
99        .ok_or(SocialError::Configuration)?;
100    for installed in [app.app_root.as_deref(), app.artifact_dir.as_deref()]
101        .into_iter()
102        .flatten()
103    {
104        if root.starts_with(installed) {
105            return Err(SocialError::Configuration);
106        }
107    }
108    let root = root.join("browser-sessions");
109    stdfs::create_dir_all(&root).map_err(|_| SocialError::Transport)?;
110    use std::os::unix::fs::PermissionsExt;
111    if stdfs::symlink_metadata(&root)
112        .map_err(|_| SocialError::Transport)?
113        .file_type()
114        .is_symlink()
115    {
116        return Err(SocialError::Forbidden);
117    }
118    stdfs::set_permissions(&root, stdfs::Permissions::from_mode(0o700))
119        .map_err(|_| SocialError::Transport)?;
120    Ok(root.join(reference(scope, provider)))
121}
122/// Maps only fixed browser diagnostics; no protocol responses, cookies or profile paths enter user errors.
123fn error(error: BrowserError) -> SocialError {
124    match error {
125        BrowserError::Blocked => SocialError::Forbidden,
126        BrowserError::Capacity => SocialError::Capacity,
127        BrowserError::Closed | BrowserError::Unavailable => SocialError::Transport,
128        BrowserError::InvalidResponse => SocialError::InvalidResponse,
129    }
130}
131/// Finds only this authenticated owner's active sign-in, additionally bound to its initiating local session.
132fn pending(
133    scope: &ConnectionScope,
134    provider: SocialProvider,
135    session: &str,
136) -> Result<Option<Arc<Mutex<PendingBrowser>>>, SocialError> {
137    let handle = registry()
138        .lock()
139        .map_err(|_| SocialError::Busy)?
140        .get(&reference(scope, provider))
141        .cloned();
142    if let Some(handle) = &handle {
143        let value = handle.lock().map_err(|_| SocialError::Busy)?;
144        if value.scope != *scope || value.provider != provider || value.session != session {
145            return Err(SocialError::Busy);
146        }
147    }
148    Ok(handle)
149}
150/// Stops expired or user-closed windows while retaining their profile and saved authentication state.
151pub(super) fn maintenance() {
152    let handles = match registry().lock() {
153        Ok(map) => map
154            .iter()
155            .map(|(key, value)| (key.clone(), value.clone()))
156            .collect::<Vec<_>>(),
157        Err(_) => return,
158    };
159    for (key, handle) in handles {
160        let expired = handle.try_lock().is_ok_and(|mut value| {
161            value.created.elapsed() > Duration::from_secs(1200) || !value.browser.is_open()
162        });
163        if expired {
164            let removed = registry().lock().ok().and_then(|mut map| map.remove(&key));
165            drop(removed);
166        }
167    }
168}
169/// Closes only framework-owned browser processes on orderly shutdown; persistent profiles are preserved.
170pub(super) fn shutdown() {
171    let handles = registry()
172        .lock()
173        .map(|mut values| std::mem::take(&mut *values))
174        .unwrap_or_default();
175    drop(handles);
176}
177/// Handles the browser mode behind the same local authentication, capability, origin and CSRF gates as API mode.
178#[allow(clippy::too_many_arguments)]
179pub(super) fn handle(
180    app: &AppRuntime,
181    request: &http::HttpRequest,
182    declaration: &Value,
183    provider: SocialProvider,
184    operation: &str,
185    scope: &ConnectionScope,
186    store: &database::LogicalAppStore,
187    session: &str,
188    capability: &str,
189) -> Result<Vec<u8>, SocialError> {
190    // Status reads durable metadata and must not wait behind a network-bound collection.
191    let operation_lock = (operation != "status")
192        .then(|| operation_lock(scope, provider))
193        .transpose()?;
194    let _operation_guard = operation_lock
195        .as_ref()
196        .map(|lock| lock.lock().map_err(|_| SocialError::Busy))
197        .transpose()?;
198    maintenance();
199    let destination = return_destination(declaration)?;
200    let available = executable(app).is_ok();
201    let vault = vault(app)?;
202    let guards = authority_guards(app, store, scope.owner(), capability, Some(session))?;
203    let repository = ConnectionRepository {
204        adapter: store.adapter.as_ref(),
205        config: &store.config,
206        schema: &store.schema,
207        vault: &vault,
208        guards: &guards,
209    };
210    let row = repository.browser(scope, provider)?;
211    if operation == "status" {
212        let pending = pending(scope, provider, session)?;
213        if request.target.contains("format=json") || request.target.contains("format%3Djson") {
214            let page_ready = if let Some(handle) = &pending {
215                let mut value = handle.lock().map_err(|_| SocialError::Busy)?;
216                let target = value.target.clone();
217                value.browser.page_ready(&target).unwrap_or(false)
218            } else {
219                false
220            };
221            return Ok(http_json_response(200,"OK",&json!({"provider":provider.key(),"mode":"browser","page_ready":page_ready,"configured":available,"connection_scope":"owner","namespace":scope.namespace(),"authorization_pending":pending.is_some(),"connection":row}).to_string(),&request.method));
222        }
223        return status_response(
224            app,
225            request,
226            declaration,
227            None,
228            provider,
229            scope,
230            row,
231            session,
232            None,
233            Some(BrowserStatus {
234                available,
235                pending: pending.is_some(),
236            }),
237        );
238    }
239    match operation {
240        "connect" => {
241            if super::super::runtime_shutdown_requested() {
242                return Err(SocialError::Busy);
243            }
244            if pending(scope, provider, session)?.is_some() {
245                return Err(SocialError::Busy);
246            }
247            let executable = executable(app)?;
248            let profile = profile(app, scope, provider)?;
249            let mut sign_in_hosts =
250                if provider == SocialProvider::X || provider.needs_account_page() {
251                    pharos_runtime_integrations::browser::GOOGLE_SIGN_IN_HOSTS.to_vec()
252                } else {
253                    Vec::new()
254                };
255            if provider.needs_account_page() {
256                sign_in_hosts
257                    .extend_from_slice(pharos_runtime_integrations::browser::ACCOUNT_SIGN_IN_HOSTS);
258            }
259            let mut browser = Browser::launch_with_sign_in_hosts(
260                &executable,
261                &profile,
262                sessions::domains(provider),
263                true,
264                &sign_in_hosts,
265            )
266            .map_err(error)?;
267            if let Some(row) = row.as_ref().filter(|row| !row.credential_ref.is_empty()) {
268                let cookies = checkpoint_for_retained_profile(&repository, scope, provider, row)?;
269                match restore_retained_session(
270                    &mut browser,
271                    provider,
272                    &row.account_id,
273                    cookies.as_deref().map(String::as_str).unwrap_or_default(),
274                ) {
275                    Ok(()) | Err(SocialError::AuthorizationRequired) => {}
276                    Err(problem) => return Err(problem),
277                }
278            }
279            let target = browser.open(sessions::home(provider)).map_err(error)?;
280            let handle = Arc::new(Mutex::new(PendingBrowser {
281                scope: scope.clone(),
282                provider,
283                session: session.into(),
284                browser,
285                target,
286                previous: row,
287                created: std::time::Instant::now(),
288            }));
289            let mut map = registry().lock().map_err(|_| SocialError::Busy)?;
290            if map.len() >= 4 || map.contains_key(&reference(scope, provider)) {
291                return Err(SocialError::Busy);
292            }
293            map.insert(reference(scope, provider), handle);
294        }
295        "finish" => {
296            let Some(handle) = pending(scope, provider, session)? else {
297                return status_response(
298                    app,
299                    request,
300                    declaration,
301                    None,
302                    provider,
303                    scope,
304                    row,
305                    session,
306                    None,
307                    Some(BrowserStatus {
308                        available,
309                        pending: false,
310                    }),
311                );
312            };
313            let mut value = handle.lock().map_err(|_| SocialError::Busy)?;
314            let target = value.target.clone();
315            let ready = match value.browser.authentication_ready(&target) {
316                Ok(ready) => ready,
317                Err(problem) => {
318                    eprintln!(
319                        "browser connection navigation check unavailable: {}",
320                        error(problem).code()
321                    );
322                    false
323                }
324            };
325            if !ready {
326                return status_response(
327                    app,
328                    request,
329                    declaration,
330                    None,
331                    provider,
332                    scope,
333                    row,
334                    session,
335                    None,
336                    Some(BrowserStatus {
337                        available,
338                        pending: true,
339                    }),
340                );
341            }
342            let cookies = match value.browser.cookies() {
343                Ok(cookies) => cookies,
344                Err(problem) => {
345                    eprintln!(
346                        "browser connection cookie checkpoint unavailable: {}",
347                        error(problem).code()
348                    );
349                    consent_ui::record(scope, provider, session, Some(consent_ui::Outcome::Failed));
350                    return status_response(
351                        app,
352                        request,
353                        declaration,
354                        None,
355                        provider,
356                        scope,
357                        row,
358                        session,
359                        None,
360                        Some(BrowserStatus {
361                            available,
362                            pending: true,
363                        }),
364                    );
365                }
366            };
367            let identity = if provider.needs_account_page() {
368                value
369                    .browser
370                    .account_identity(&target, provider.key())
371                    .map_err(error)?
372                    .map(|identity| {
373                        dynamic::hex_digest(&Sha256::digest(
374                            format!("{}\0{identity}", provider.key()).as_bytes(),
375                        ))
376                    })
377            } else {
378                sessions::account(provider, &cookies).ok()
379            };
380            if identity.as_deref().is_none_or(|identity| {
381                sessions::account_with_identity(provider, &cookies, Some(identity)).is_err()
382            }) {
383                return status_response(
384                    app,
385                    request,
386                    declaration,
387                    None,
388                    provider,
389                    scope,
390                    row,
391                    session,
392                    None,
393                    Some(BrowserStatus {
394                        available,
395                        pending: true,
396                    }),
397                );
398            }
399            let identity = identity.ok_or(SocialError::AuthorizationRequired)?;
400            require_session(store, session, scope.owner())?;
401            let saved = match repository.save_browser_identity(
402                scope,
403                provider,
404                value.previous.as_ref(),
405                &cookies,
406                &identity,
407            ) {
408                Ok(row) => row,
409                Err(error) => {
410                    eprintln!("browser connection checkpoint failed: {}", error.code());
411                    consent_ui::record(scope, provider, session, Some(consent_ui::Outcome::Failed));
412                    return status_response(
413                        app,
414                        request,
415                        declaration,
416                        None,
417                        provider,
418                        scope,
419                        row,
420                        session,
421                        None,
422                        Some(BrowserStatus {
423                            available,
424                            pending: true,
425                        }),
426                    );
427                }
428            };
429            consent_ui::record(scope, provider, session, None);
430            value.browser.close();
431            drop(value);
432            let removed = registry()
433                .lock()
434                .map_err(|_| SocialError::Busy)?
435                .remove(&reference(scope, provider));
436            drop(removed);
437            use pharos_runtime_database::operations::{Mutation, Predicate};
438            let mut completion_guards = guards.clone();
439            completion_guards.push(Mutation::RequireCount {
440                entity: "social_connection".into(),
441                filter: Predicate::All(vec![
442                    Predicate::Equal("application".into(), json!(scope.application())),
443                    Predicate::Equal("namespace".into(), json!(scope.namespace())),
444                    Predicate::Equal("owner".into(), json!(scope.owner())),
445                    Predicate::Equal("provider".into(), json!(saved.provider)),
446                    Predicate::Equal("revision".into(), json!(saved.revision)),
447                    Predicate::Equal("status".into(), json!("connected")),
448                ]),
449                minimum: 1,
450                maximum: Some(1),
451            });
452            if super::super::event_discovery::social_connection_available(
453                app,
454                provider,
455                &completion_guards,
456            )
457            .is_err()
458            {
459                eprintln!("event sources could not be rescheduled after browser connection");
460            }
461            return status_response(
462                app,
463                request,
464                declaration,
465                None,
466                provider,
467                scope,
468                Some(saved),
469                session,
470                None,
471                Some(BrowserStatus {
472                    available,
473                    pending: false,
474                }),
475            );
476        }
477        "cancel" => {
478            let _ = pending(scope, provider, session)?;
479            let removed = registry()
480                .lock()
481                .map_err(|_| SocialError::Busy)?
482                .remove(&reference(scope, provider));
483            drop(removed);
484        }
485        "disconnect" => {
486            let _ = pending(scope, provider, session)?;
487            let revoked = row
488                .as_ref()
489                .filter(|row| row.status != "disconnected" || !row.credential_ref.is_empty())
490                .map(|row| repository.disconnect(row))
491                .transpose()?;
492            let removed = registry()
493                .lock()
494                .map_err(|_| SocialError::Busy)?
495                .remove(&reference(scope, provider));
496            drop(removed);
497            let profile = profile(app, scope, provider)?;
498            if profile.exists() {
499                if stdfs::symlink_metadata(&profile)
500                    .map_err(|_| SocialError::Transport)?
501                    .file_type()
502                    .is_symlink()
503                {
504                    return Err(SocialError::Forbidden);
505                }
506                stdfs::remove_dir_all(profile).map_err(|_| SocialError::Transport)?;
507            }
508            if let Some(row) = revoked {
509                repository.finish_disconnect(&row)?;
510            }
511        }
512        _ => return Err(SocialError::Forbidden),
513    }
514    Ok(http_route_redirect_for_request(
515        request,
516        &app_scope_path(app, destination),
517    ))
518}
519
520/// Resolves an explicitly requested unique connected owner without borrowing another namespace or guessing among grants.
521fn selected_owner(app: &AppRuntime, provider: SocialProvider) -> Result<Option<i64>, SocialError> {
522    use pharos_runtime_database::operations::{Predicate, Query};
523    let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
524        .ok_or(SocialError::Configuration)?;
525    if declaration.get("collection_owner").and_then(Value::as_str) != Some("connected") {
526        return Ok(None);
527    }
528    let scope = scope(app, 1)?;
529    let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
530    let rows = store
531        .adapter
532        .query(
533            &store.config,
534            &store.schema,
535            &Query {
536                entity: "social_connection".into(),
537                fields: vec!["owner".into()],
538                filter: Some(Predicate::All(vec![
539                    Predicate::Equal("application".into(), json!(scope.application())),
540                    Predicate::Equal("namespace".into(), json!(scope.namespace())),
541                    Predicate::Equal("provider".into(), json!(sessions::key(provider))),
542                    Predicate::Equal("status".into(), json!("connected")),
543                ])),
544                order: vec![],
545                limit: 2,
546                offset: 0,
547            },
548        )
549        .map_err(|_| SocialError::Transport)?;
550    if rows.len() != 1 {
551        return Err(SocialError::AuthorizationRequired);
552    }
553    Ok(Some(
554        rows[0]
555            .get("owner")
556            .and_then(Value::as_i64)
557            .filter(|owner| *owner > 0)
558            .ok_or(SocialError::Configuration)?,
559    ))
560}
561/// Allows recovery from a missing checkpoint only through the same scope-bound profile and recorded provider account.
562fn checkpoint_for_retained_profile(
563    repository: &ConnectionRepository<'_>,
564    scope: &ConnectionScope,
565    provider: SocialProvider,
566    row: &ConnectionRecord,
567) -> Result<Option<zeroize::Zeroizing<String>>, SocialError> {
568    match repository.browser_reconnect_cookies(scope, provider, row) {
569        Ok(cookies) => Ok(Some(cookies)),
570        Err(SocialError::AuthorizationRequired)
571            if row.application == scope.application()
572                && row.namespace == scope.namespace()
573                && row.owner == scope.owner()
574                && row.provider == sessions::key(provider)
575                && !row.account_id.is_empty()
576                && matches!(
577                    row.status.as_str(),
578                    "connected" | "reauthorization_required" | "disconnected"
579                ) =>
580        {
581            Ok(None)
582        }
583        Err(problem) => Err(problem),
584    }
585}
586
587/// Keeps newer provider cookies from the retained profile instead of replaying an older vault checkpoint.
588fn restore_retained_session(
589    browser: &mut Browser,
590    provider: SocialProvider,
591    account_id: &str,
592    checkpoint: &str,
593) -> Result<(), SocialError> {
594    let retained = browser.cookies().map_err(error)?;
595    // Account-page providers are verified again before promotion; cookie presence alone never saves a connection.
596    match sessions::account_with_identity(provider, &retained, Some(account_id)) {
597        Ok(account) if account == account_id => Ok(()),
598        Ok(_) => Err(SocialError::Forbidden),
599        Err(_) if checkpoint.is_empty() => Err(SocialError::AuthorizationRequired),
600        Err(_) => browser.restore(checkpoint).map_err(error),
601    }
602}
603
604/// Checks the provider home separately so an unavailable artist resource cannot expire the owner's whole connection.
605fn provider_home_ready(
606    browser: &mut Browser,
607    provider: SocialProvider,
608) -> Result<bool, SocialError> {
609    let target = browser.open(sessions::home(provider)).map_err(error)?;
610    let started = std::time::Instant::now();
611    loop {
612        if started.elapsed() >= Duration::from_secs(2)
613            && browser.page_ready(&target).map_err(error)?
614            && browser.authentication_ready(&target).map_err(error)?
615        {
616            return Ok(true);
617        }
618        if started.elapsed() >= Duration::from_secs(5) {
619            return Ok(false);
620        }
621        std::thread::sleep(Duration::from_millis(250));
622    }
623}
624
625/// Persists a still-valid provider session after a resource failure, or requests owner sign-in only when home is also blocked.
626fn settle_blocked_resource(
627    browser: &mut Browser,
628    repository: &ConnectionRepository<'_>,
629    scope: &ConnectionScope,
630    provider: SocialProvider,
631    leased: &ConnectionRecord,
632) -> Result<(), SocialError> {
633    if provider_home_ready(browser, provider)? {
634        let retained = browser.cookies().map_err(error)?;
635        if sessions::account(provider, &retained).ok().as_deref()
636            == Some(leased.account_id.as_str())
637        {
638            repository.save_browser(scope, provider, Some(leased), &retained)?;
639            return Ok(());
640        }
641    }
642    repository.browser_needs_login(leased)
643}
644
645/// Collects one bounded rendered page under durable owner/connection authority and retains private evidence.
646pub(super) fn observations(
647    app: &AppRuntime,
648    profile_url: &str,
649) -> Result<ObservationBatch, SocialError> {
650    use pharos_runtime_database::operations::{Mutation, Predicate, Transaction, WriteValue};
651    let parsed = Url::parse(profile_url).map_err(|_| SocialError::InvalidResponse)?;
652    let provider = SocialProvider::from_url(&parsed).ok_or(SocialError::UnsupportedProvider)?;
653    let selected = selected_owner(app, provider).inspect_err(|problem| {
654        eprintln!(
655            "social collection owner unavailable: provider={} code={}",
656            provider.key(),
657            problem.code()
658        );
659    })?;
660    let (scope, store) = collection_authority(app, selected).inspect_err(|problem| {
661        eprintln!(
662            "social collection authority unavailable: provider={} code={}",
663            provider.key(),
664            problem.code()
665        );
666    })?;
667    let operation_lock = operation_lock(&scope, provider)?;
668    let _operation_guard = operation_lock.lock().map_err(|_| SocialError::Busy)?;
669    if registry()
670        .lock()
671        .map_err(|_| SocialError::Busy)?
672        .contains_key(&reference(&scope, provider))
673    {
674        return Err(SocialError::Busy);
675    }
676    let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
677        .ok_or(SocialError::Configuration)?;
678    let capability = declaration
679        .get("owner_capability")
680        .and_then(Value::as_str)
681        .ok_or(SocialError::Configuration)?;
682    let guards = authority_guards(app, &store, scope.owner(), capability, None)?;
683    let vault = vault(app)?;
684    let repository = ConnectionRepository {
685        adapter: store.adapter.as_ref(),
686        config: &store.config,
687        schema: &store.schema,
688        vault: &vault,
689        guards: &guards,
690    };
691    let row = repository
692        .browser(&scope, provider)?
693        .ok_or(SocialError::AuthorizationRequired)?;
694    let now = SystemTime::now()
695        .duration_since(UNIX_EPOCH)
696        .map_err(|_| SocialError::Configuration)?
697        .as_secs() as i64;
698    let cookies = checkpoint_for_retained_profile(&repository, &scope, provider, &row)?;
699    let leased = repository.lease_refresh(&row, now)?;
700    let mut checkpoint_saved = false;
701    let result = (|| {
702        let mut browser = Browser::launch(
703            &executable(app)?,
704            &profile(app, &scope, provider)?,
705            sessions::domains(provider),
706            false,
707        )
708        .map_err(error)?;
709        if let Err(problem) = restore_retained_session(
710            &mut browser,
711            provider,
712            &row.account_id,
713            cookies.as_deref().map(String::as_str).unwrap_or_default(),
714        ) {
715            if problem == SocialError::AuthorizationRequired {
716                repository.browser_needs_login(&leased)?;
717            }
718            return Err(problem);
719        }
720        let restored = browser.cookies().map_err(error)?;
721        if sessions::account(provider, &restored).ok().as_deref() != Some(row.account_id.as_str()) {
722            repository.browser_needs_login(&leased)?;
723            return Err(SocialError::AuthorizationRequired);
724        }
725        let target = browser.open(profile_url).map_err(error)?;
726        let collection_started = std::time::Instant::now();
727        let mut poster_budget = 2;
728        let mut date_budget = 8;
729        let snapshot = collection_snapshot(
730            app,
731            &mut browser,
732            &target,
733            profile_url,
734            profile_url,
735            &mut poster_budget,
736            &mut date_budget,
737        );
738        let mut snapshot = match snapshot {
739            Ok(snapshot) => snapshot,
740            Err(SocialError::AuthorizationRequired) => {
741                eprintln!(
742                    "social artist resource redirected: provider={}",
743                    provider.key()
744                );
745                settle_blocked_resource(&mut browser, &repository, &scope, provider, &leased)?;
746                return Err(SocialError::AuthorizationRequired);
747            }
748            Err(error) => return Err(error),
749        };
750        let resources = snapshot
751            .get("post_resources")
752            .and_then(Value::as_array)
753            .cloned()
754            .unwrap_or_default();
755        let mut resource_count = 0;
756        let mut detail_dates = Vec::new();
757        let mut detail_diagnostics = Vec::new();
758        for resource in resources.iter().filter_map(Value::as_str).take(4) {
759            if collection_started.elapsed() >= Duration::from_secs(20) {
760                break;
761            }
762            resource_count += 1;
763            let post_target = match browser.open(resource) {
764                Ok(target) => target,
765                Err(problem) => {
766                    eprintln!(
767                        "browser optional detail unavailable: {}",
768                        error(problem).code()
769                    );
770                    continue;
771                }
772            };
773            let page = match collection_snapshot(
774                app,
775                &mut browser,
776                &post_target,
777                resource,
778                profile_url,
779                &mut poster_budget,
780                &mut date_budget,
781            ) {
782                Ok(page) => page,
783                Err(problem) => {
784                    eprintln!("browser optional detail unavailable: {}", problem.code());
785                    continue;
786                }
787            };
788            let diagnostics = page.get("diagnostics").unwrap_or(&Value::Null);
789            detail_diagnostics.push(json!({"headings":diagnostics.get("heading_count"),"messages":diagnostics.get("message_nodes"),"attributed":diagnostics.get("attributed"),"profile_links":diagnostics.get("profile_links"),"main_links":diagnostics.get("main_link_count"),"privacy":diagnostics.get("instagram_privacy")}));
790            detail_dates.extend(
791                page.pointer("/diagnostics/posting_dates")
792                    .and_then(Value::as_array)
793                    .into_iter()
794                    .flatten()
795                    .take(4)
796                    .cloned(),
797            );
798            for key in ["posts", "links", "structured"] {
799                let additions = page
800                    .get(key)
801                    .and_then(Value::as_array)
802                    .cloned()
803                    .unwrap_or_default();
804                if let Some(values) = snapshot.get_mut(key).and_then(Value::as_array_mut) {
805                    for value in additions.into_iter().take(20) {
806                        if values.len() < 60 && !values.contains(&value) {
807                            values.push(value);
808                        }
809                    }
810                }
811            }
812        }
813        snapshot["diagnostics"]["detail_pages"] = json!(resource_count);
814        snapshot["diagnostics"]["detail_posting_dates"] = json!(detail_dates);
815        snapshot["diagnostics"]["detail_views"] = json!(detail_diagnostics);
816        snapshot["text"] = json!(
817            snapshot
818                .get("posts")
819                .and_then(Value::as_array)
820                .map(|posts| posts
821                    .iter()
822                    .filter_map(|post| post.get("text").and_then(Value::as_str))
823                    .take(20)
824                    .collect::<Vec<_>>()
825                    .join("\n"))
826                .unwrap_or_default()
827        );
828        let current = snapshot
829            .get("url")
830            .and_then(Value::as_str)
831            .ok_or(SocialError::InvalidResponse)?;
832        let refreshed = browser.cookies().map_err(error)?;
833        if sessions::account(provider, &refreshed).ok().as_deref() != Some(row.account_id.as_str())
834        {
835            repository.browser_needs_login(&leased)?;
836            return Err(SocialError::AuthorizationRequired);
837        }
838        if !browser.allowed_url(current)
839            || snapshot.get("login").and_then(Value::as_bool) == Some(true)
840        {
841            eprintln!(
842                "social artist resource login barrier: provider={}",
843                provider.key()
844            );
845            settle_blocked_resource(&mut browser, &repository, &scope, provider, &leased)?;
846            return Err(SocialError::AuthorizationRequired);
847        }
848        let row = repository.save_browser(&scope, provider, Some(&leased), &refreshed)?;
849        checkpoint_saved = true;
850        let mut evidence = snapshot
851            .get("structured")
852            .and_then(Value::as_array)
853            .map(|values| values.iter().take(10).cloned().collect::<Vec<_>>())
854            .unwrap_or_default();
855        // Event links retain their source context even when the provider supplies no structured event declaration.
856        for post in snapshot
857            .get("posts")
858            .and_then(Value::as_array)
859            .into_iter()
860            .flatten()
861            .take(10)
862        {
863            let text = post.get("text").and_then(Value::as_str).unwrap_or("");
864            let text = &text[..text.floor_char_boundary(3500)];
865            if evidence.len() < 40
866                && (!text.trim().is_empty()
867                    || post
868                        .get("image_text")
869                        .and_then(Value::as_str)
870                        .is_some_and(|text| !text.trim().is_empty()))
871            {
872                evidence.push(json!({"url":post.get("permalink").and_then(Value::as_str).unwrap_or(profile_url),"description":text,"source_profile":profile_url,"evidence_kind":"rendered_post","audience":post.get("audience"),"datePublished":post.get("published_at"),"publication_labels":post.get("publication_labels"),"image_text":post.get("image_text"),"image_status":post.get("image_status"),"author_url":post.get("author_url")}));
873            }
874            for link in post
875                .get("links")
876                .and_then(Value::as_array)
877                .into_iter()
878                .flatten()
879                .filter_map(Value::as_str)
880                .take(20)
881            {
882                if facebook_event_url(link).is_some() && evidence.len() < 40 {
883                    evidence.push(json!({"evidence_kind":"rendered_post","url":link,"description":text,"source_profile":profile_url,"datePublished":post.get("published_at"),"publication_labels":post.get("publication_labels"),"image_text":post.get("image_text")}));
884                    break;
885                }
886            }
887        }
888        let event_links = snapshot
889            .get("links")
890            .and_then(Value::as_array)
891            .into_iter()
892            .flatten()
893            .filter_map(|link| link.get("url").and_then(Value::as_str))
894            .filter_map(facebook_event_url)
895            .collect::<std::collections::BTreeSet<_>>();
896        for link in event_links
897            .iter()
898            .take(2usize.saturating_sub(resource_count))
899        {
900            let target = match browser.open(link) {
901                Ok(target) => target,
902                Err(problem) => {
903                    eprintln!(
904                        "browser optional detail unavailable: {}",
905                        error(problem).code()
906                    );
907                    continue;
908                }
909            };
910            let page = match collection_snapshot(
911                app,
912                &mut browser,
913                &target,
914                link,
915                profile_url,
916                &mut poster_budget,
917                &mut date_budget,
918            ) {
919                Ok(page) => page,
920                Err(problem) => {
921                    eprintln!("browser optional detail unavailable: {}", problem.code());
922                    continue;
923                }
924            };
925            if page.get("login").and_then(Value::as_bool) == Some(true) {
926                continue;
927            }
928            for value in page
929                .get("structured")
930                .and_then(Value::as_array)
931                .into_iter()
932                .flatten()
933                .take(3)
934            {
935                if evidence.len() < 40 {
936                    evidence.push(value.clone());
937                }
938            }
939        }
940        browser.close();
941        let mut links = std::collections::BTreeSet::new();
942        for item in snapshot
943            .get("links")
944            .and_then(Value::as_array)
945            .into_iter()
946            .flatten()
947            .take(1000)
948        {
949            let Some(value) = item.get("url").and_then(Value::as_str) else {
950                continue;
951            };
952            let Ok(mut url) = Url::parse(value) else {
953                continue;
954            };
955            if matches!(
956                url.host_str(),
957                Some("l.facebook.com" | "lm.facebook.com" | "l.instagram.com")
958            ) && let Some(value) = url
959                .query_pairs()
960                .find(|(key, _)| key == "u")
961                .map(|(_, value)| value.into_owned())
962                && let Ok(decoded) = Url::parse(&value)
963            {
964                url = decoded;
965            }
966            if url.scheme() == "https"
967                && url.username().is_empty()
968                && url.password().is_none()
969                && url.as_str().len() <= 2048
970            {
971                if evidence.len() < 40 && SocialProvider::from_url(&url).is_none() {
972                    let context = item.get("context").and_then(Value::as_str).unwrap_or("");
973                    let context = &context[..context.floor_char_boundary(3500)];
974                    evidence.push(json!({"evidence_kind":"rendered_link","url":url.as_str(),"description":context,"source_profile":profile_url,"datePublished":item.get("published_at"),"publication_labels":item.get("publication_labels"),"author_url":item.get("author_url")}));
975                }
976                links.insert(url.to_string());
977            }
978        }
979        let mut guards = guards.clone();
980        guards.push(Mutation::RequireCount {
981            entity: "social_connection".into(),
982            filter: Predicate::All(vec![
983                Predicate::Equal("application".into(), json!(scope.application())),
984                Predicate::Equal("namespace".into(), json!(scope.namespace())),
985                Predicate::Equal("owner".into(), json!(scope.owner())),
986                Predicate::Equal("provider".into(), json!(sessions::key(provider))),
987                Predicate::Equal("revision".into(), json!(row.revision)),
988                Predicate::Equal("status".into(), json!("connected")),
989            ]),
990            minimum: 1,
991            maximum: Some(1),
992        });
993        let text = snapshot.get("text").and_then(Value::as_str).unwrap_or("");
994        let text = format!(
995            "Attributed browser posts v3; public evidence required before publication.\n{}\nCapture diagnostics: {}",
996            &text[..text.floor_char_boundary(16000)],
997            snapshot.get("diagnostics").cloned().unwrap_or(Value::Null)
998        );
999        let target = pharos_runtime_database::operations::operation_fingerprint(&profile_url)
1000            .map_err(|_| SocialError::InvalidResponse)?;
1001        let post = pharos_runtime_database::operations::operation_fingerprint(&(
1002            profile_url,
1003            &text,
1004            &links,
1005        ))
1006        .map_err(|_| SocialError::InvalidResponse)?;
1007        let observation = json!({"application":scope.application(),"namespace":scope.namespace(),"owner":scope.owner(),"provider":sessions::key(provider),"target":target,"post_id":post,"published_at":"","permalink":profile_url,"text":text,"links":serde_json::to_string(&links).map_err(|_|SocialError::InvalidResponse)?});
1008        let values = observation
1009            .as_object()
1010            .ok_or(SocialError::InvalidResponse)?
1011            .iter()
1012            .map(|(key, value)| (key.clone(), WriteValue::Literal(value.clone())))
1013            .collect();
1014        let mut steps = guards.clone();
1015        steps.extend(legacy_observation_redactions(&store, &scope, provider)?);
1016        steps.push(Mutation::InsertIfAbsent {
1017            entity: "social_observation".into(),
1018            unique_key: vec![
1019                "application".into(),
1020                "namespace".into(),
1021                "owner".into(),
1022                "provider".into(),
1023                "target".into(),
1024                "post_id".into(),
1025            ],
1026            values,
1027        });
1028        store
1029            .adapter
1030            .transact(
1031                &store.config,
1032                &store.schema,
1033                &Transaction {
1034                    steps,
1035                    idempotency_key: None,
1036                },
1037            )
1038            .map_err(|_| SocialError::Busy)?;
1039        let posts = snapshot.get("posts").and_then(Value::as_array);
1040        let unverified_posts = match posts {
1041            Some(posts) if !posts.is_empty() => posts
1042                .iter()
1043                .filter(|post| {
1044                    !super::super::event_discovery::social_evidence::posting_date_known(post)
1045                })
1046                .count(),
1047            _ => snapshot
1048                .pointer("/diagnostics/message_nodes")
1049                .and_then(Value::as_u64)
1050                .unwrap_or(0) as usize,
1051        };
1052        let verified_posts = posts
1053            .map(|posts| {
1054                posts
1055                    .iter()
1056                    .filter(|post| {
1057                        super::super::event_discovery::social_evidence::posting_date_known(post)
1058                    })
1059                    .count()
1060            })
1061            .unwrap_or(0);
1062        Ok(ObservationBatch {
1063            links: links.into_iter().collect(),
1064            evidence,
1065            unverified_posts,
1066            verified_posts,
1067            guards,
1068        })
1069    })();
1070    if result.is_err() && !checkpoint_saved {
1071        match repository.release_browser_lease(&leased) {
1072            Ok(()) | Err(SocialError::Busy | SocialError::Forbidden) => {}
1073            Err(problem) => eprintln!(
1074                "browser collection lease cleanup failed: {}",
1075                problem.code()
1076            ),
1077        }
1078    }
1079    result
1080}
1081
1082/// Redacts legacy whole-document browser content through scoped compare-and-swap operations, preserving observation identities.
1083fn legacy_observation_redactions(
1084    store: &super::super::database::LogicalAppStore,
1085    scope: &ConnectionScope,
1086    provider: SocialProvider,
1087) -> Result<Vec<pharos_runtime_database::operations::Mutation>, SocialError> {
1088    use pharos_runtime_database::operations::{Mutation, Predicate, Query, WriteValue};
1089    let prefixes = [
1090        "Partial browser view; public evidence required before publication.",
1091        "Scoped browser content v2; public evidence required before publication.",
1092    ];
1093    let scope_filter = vec![
1094        Predicate::Equal("application".into(), json!(scope.application())),
1095        Predicate::Equal("namespace".into(), json!(scope.namespace())),
1096        Predicate::Equal("owner".into(), json!(scope.owner())),
1097        Predicate::Equal("provider".into(), json!(sessions::key(provider))),
1098    ];
1099    let mut filter = scope_filter.clone();
1100    filter.push(Predicate::Any(
1101        prefixes
1102            .iter()
1103            .map(|prefix| Predicate::Contains("text".into(), (*prefix).into()))
1104            .collect(),
1105    ));
1106    let rows = store
1107        .adapter
1108        .query(
1109            &store.config,
1110            &store.schema,
1111            &Query {
1112                entity: "social_observation".into(),
1113                fields: vec![
1114                    "target".into(),
1115                    "post_id".into(),
1116                    "text".into(),
1117                    "links".into(),
1118                ],
1119                filter: Some(Predicate::All(filter)),
1120                order: vec![],
1121                limit: 100,
1122                offset: 0,
1123            },
1124        )
1125        .map_err(|_| SocialError::Transport)?;
1126    let mut steps = Vec::new();
1127    for row in rows {
1128        if !row
1129            .get("text")
1130            .and_then(Value::as_str)
1131            .is_some_and(|text| prefixes.iter().any(|prefix| text.starts_with(prefix)))
1132        {
1133            continue;
1134        }
1135        let mut filter = scope_filter.clone();
1136        for field in ["target", "post_id", "text", "links"] {
1137            let value = row
1138                .get(field)
1139                .filter(|value| value.is_string())
1140                .ok_or(SocialError::InvalidResponse)?;
1141            filter.push(Predicate::Equal(field.into(), value.clone()));
1142        }
1143        steps.push(Mutation::Update {
1144            entity: "social_observation".into(),
1145            filter: Predicate::All(filter),
1146            values: std::collections::BTreeMap::from([
1147                (
1148                    "text".into(),
1149                    WriteValue::Literal(json!(
1150                        "[redacted: legacy browser snapshot lacked verified post attribution]"
1151                    )),
1152                ),
1153                ("links".into(), WriteValue::Literal(json!("[]"))),
1154            ]),
1155            expected_rows: Some(1),
1156        });
1157    }
1158    Ok(steps)
1159}
1160
1161/// Accepts only concrete Facebook event IDs observed inside the authorized profile view.
1162fn facebook_event_url(value: &str) -> Option<String> {
1163    let mut url = Url::parse(value).ok()?;
1164    if url.scheme() != "https"
1165        || url.port_or_known_default() != Some(443)
1166        || !matches!(
1167            url.host_str(),
1168            Some("facebook.com" | "www.facebook.com" | "m.facebook.com")
1169        )
1170        || !url.username().is_empty()
1171        || url.password().is_some()
1172    {
1173        return None;
1174    }
1175    let segments = url
1176        .path_segments()?
1177        .filter(|segment| !segment.is_empty())
1178        .collect::<Vec<_>>();
1179    if segments.len() != 2
1180        || segments[0] != "events"
1181        || segments[1].is_empty()
1182        || !segments[1].bytes().all(|byte| byte.is_ascii_digit())
1183    {
1184        return None;
1185    }
1186    url.set_query(None);
1187    url.set_fragment(None);
1188    Some(url.to_string())
1189}
1190
1191/// Identifies the same Instagram post across root and artist-prefixed permalink forms, preserving shortcode case.
1192fn instagram_post_key(url: &Url) -> Option<(&str, &str)> {
1193    let path = url.path().trim_end_matches('/');
1194    let count = path.split('/').filter(|part| !part.is_empty()).count();
1195    if !(2..=3).contains(&count) {
1196        return None;
1197    }
1198    let (prefix, id) = path.rsplit_once('/')?;
1199    let kind = prefix.rsplit('/').next()?;
1200    (matches!(kind, "p" | "reel")
1201        && !id.is_empty()
1202        && id.len() <= 128
1203        && id
1204            .bytes()
1205            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'_' | b'-')))
1206    .then_some((kind, id))
1207}
1208
1209/// A provider redirect to the owner home feed cannot become evidence for the requested artist.
1210fn same_profile_location(expected: &str, current: &str) -> bool {
1211    let (Ok(expected), Ok(current)) = (Url::parse(expected), Url::parse(current)) else {
1212        return false;
1213    };
1214    let host = |url: &Url| {
1215        url.host_str()
1216            .unwrap_or("")
1217            .trim_start_matches("www.")
1218            .trim_start_matches("m.")
1219            .trim_start_matches("mbasic.")
1220            .to_ascii_lowercase()
1221    };
1222    expected.scheme() == "https"
1223        && current.scheme() == "https"
1224        && host(&expected) == host(&current)
1225        && (expected.path().trim_end_matches('/') == current.path().trim_end_matches('/')
1226            || host(&expected) == "instagram.com"
1227                && instagram_post_key(&expected).is_some()
1228                && instagram_post_key(&expected) == instagram_post_key(&current))
1229        && ["id", "story_fbid", "fbid"].iter().all(|key| {
1230            expected
1231                .query_pairs()
1232                .find(|(name, _)| name == key)
1233                .map(|(_, value)| value)
1234                == current
1235                    .query_pairs()
1236                    .find(|(name, _)| name == key)
1237                    .map(|(_, value)| value)
1238        })
1239}
1240
1241/// OCRs at most two visible, attributed post images within a strict helper deadline; failures remain explicit evidence.
1242fn extract_posters(
1243    app: &AppRuntime,
1244    browser: &mut Browser,
1245    target: &str,
1246    snapshot: &mut Value,
1247    budget: &mut usize,
1248) {
1249    use base64::Engine;
1250    use std::io::Write;
1251    use std::os::unix::fs::OpenOptionsExt;
1252    let Some(posts) = snapshot.get_mut("posts").and_then(Value::as_array_mut) else {
1253        return;
1254    };
1255    for post in posts {
1256        if post.get("image_status").is_some() {
1257            continue;
1258        }
1259        let now = super::super::runtime_timestamp_seconds(&super::super::current_timestamp_utc())
1260            .unwrap_or(u64::MAX);
1261        if !super::super::event_discovery::social_evidence::dated_announcement(post, now) {
1262            post["image_status"] = json!("posting_date_unverified_or_stale");
1263            continue;
1264        }
1265        let images = post
1266            .get("images")
1267            .and_then(Value::as_array)
1268            .cloned()
1269            .unwrap_or_default();
1270        if images.is_empty() {
1271            continue;
1272        }
1273        post["image_status"] = json!("not_read");
1274        for image in images
1275            .into_iter()
1276            .filter(|image| image.get("visible").and_then(Value::as_bool) == Some(true))
1277            .take(1)
1278        {
1279            if *budget == 0 {
1280                post["image_status"] = json!("budget_exceeded");
1281                break;
1282            }
1283            *budget -= 1;
1284            let result = (|| -> Result<String, SocialError> {
1285                let encoded = browser
1286                    .capture_poster(target, &image["clip"])
1287                    .map_err(error)?;
1288                let bytes = base64::engine::general_purpose::STANDARD
1289                    .decode(encoded)
1290                    .map_err(|_| SocialError::InvalidResponse)?;
1291                if bytes.len() > 2 * 1024 * 1024 {
1292                    return Err(SocialError::Capacity);
1293                }
1294                let root = app
1295                    .state_path
1296                    .as_deref()
1297                    .and_then(|path| Path::new(path).parent())
1298                    .ok_or(SocialError::Configuration)?;
1299                let name = SystemTime::now()
1300                    .duration_since(UNIX_EPOCH)
1301                    .map_err(|_| SocialError::Configuration)?
1302                    .as_nanos();
1303                let path = root.join(format!("social-poster-{name}.png"));
1304                let mut file = stdfs::OpenOptions::new()
1305                    .create_new(true)
1306                    .write(true)
1307                    .mode(0o600)
1308                    .open(&path)
1309                    .map_err(|_| SocialError::Transport)?;
1310                let extraction = (|| {
1311                    file.write_all(&bytes).map_err(|_| SocialError::Transport)?;
1312                    drop(file);
1313                    let conf = if app.is_editor_preview {
1314                        OPERATOR_CONFIG
1315                            .read()
1316                            .map_err(|_| SocialError::Configuration)?
1317                            .clone()
1318                    } else {
1319                        app.runtime_conf_path.clone()
1320                    }
1321                    .ok_or(SocialError::Configuration)?;
1322                    super::super::agent_media::extract_poster_with_platform_helper(
1323                        &path,
1324                        root,
1325                        Path::new(&conf),
1326                    )
1327                    .map_err(|_| SocialError::InvalidResponse)
1328                })();
1329                stdfs::remove_file(&path).map_err(|_| SocialError::Transport)?;
1330                extraction
1331            })();
1332            match result {
1333                Ok(text) => {
1334                    post["image_text"] = json!(text);
1335                    post["image_status"] = json!("read");
1336                }
1337                Err(_) => post["image_status"] = json!("unavailable"),
1338            }
1339        }
1340    }
1341}
1342
1343/// Resolves bounded header-only publication tooltips without reading comment dates or guessing from post prose.
1344fn resolve_publications(
1345    browser: &mut Browser,
1346    session: &str,
1347    snapshot: &mut Value,
1348    budget: &mut usize,
1349) {
1350    let targets = snapshot
1351        .get("date_targets")
1352        .and_then(Value::as_array)
1353        .cloned()
1354        .unwrap_or_default();
1355    for target in targets.iter().take(4) {
1356        if *budget == 0 {
1357            break;
1358        }
1359        *budget -= 1;
1360        let Ok(labels) = browser.publication_labels(session, target) else {
1361            continue;
1362        };
1363        if labels.as_array().is_none_or(Vec::is_empty) {
1364            continue;
1365        }
1366        if let Some(posts) = snapshot.get_mut("posts").and_then(Value::as_array_mut) {
1367            for (index, post) in posts.iter_mut().enumerate() {
1368                if target.get("post_index").and_then(Value::as_u64) == Some(index as u64)
1369                    && post.get("permalink") == target.get("permalink")
1370                {
1371                    post["publication_labels"] = labels.clone();
1372                }
1373            }
1374        }
1375    }
1376    if let Some(posts) = snapshot.get("posts").and_then(Value::as_array) {
1377        let dates=posts.iter().take(20).map(|post|json!({"published_at":post.get("published_at"),"publication_labels":post.get("publication_labels"),"audience":post.get("audience")})).collect::<Vec<_>>();
1378        snapshot["diagnostics"]["posting_dates"] = json!(dates);
1379    }
1380}
1381
1382/// Gives hydrated provider posts time to appear and merges three bounded rendered views without claiming a complete feed.
1383fn collection_snapshot(
1384    app: &AppRuntime,
1385    browser: &mut Browser,
1386    target: &str,
1387    expected: &str,
1388    source_profile: &str,
1389    poster_budget: &mut usize,
1390    date_budget: &mut usize,
1391) -> Result<Value, SocialError> {
1392    let started = std::time::Instant::now();
1393    let mut snapshot = loop {
1394        let value = match browser.snapshot_for_profile(target, Some(source_profile)) {
1395            Ok(value) => value,
1396            Err(BrowserError::InvalidResponse) if started.elapsed() < Duration::from_secs(15) => {
1397                std::thread::sleep(Duration::from_millis(500));
1398                continue;
1399            }
1400            Err(problem) => return Err(error(problem)),
1401        };
1402        if value
1403            .get("navigation_error")
1404            .and_then(Value::as_str)
1405            .is_some()
1406        {
1407            return Err(SocialError::Transport);
1408        }
1409        if started.elapsed() >= Duration::from_secs(3)
1410            && value.get("ready").and_then(Value::as_bool) == Some(true)
1411        {
1412            if !value
1413                .get("url")
1414                .and_then(Value::as_str)
1415                .is_some_and(|current| same_profile_location(expected, current))
1416            {
1417                let path = value
1418                    .get("url")
1419                    .and_then(Value::as_str)
1420                    .and_then(|url| Url::parse(url).ok())
1421                    .map(|url| url.path().to_owned())
1422                    .unwrap_or_default();
1423                eprintln!(
1424                    "social resource navigation rejected: path={} login={}",
1425                    path,
1426                    value.get("login").and_then(Value::as_bool).unwrap_or(false)
1427                );
1428                return Err(SocialError::AuthorizationRequired);
1429            }
1430            break value;
1431        }
1432        if started.elapsed() >= Duration::from_secs(15) {
1433            eprintln!(
1434                "social browser content not ready: diagnostics={}",
1435                value.get("diagnostics").unwrap_or(&Value::Null)
1436            );
1437            return Err(SocialError::Transport);
1438        }
1439        std::thread::sleep(Duration::from_millis(500));
1440    };
1441    resolve_publications(browser, target, &mut snapshot, date_budget);
1442    extract_posters(app, browser, target, &mut snapshot, poster_budget);
1443    if expected != source_profile {
1444        return Ok(snapshot);
1445    }
1446    for _ in 0..2 {
1447        if snapshot.get("login").and_then(Value::as_bool) == Some(true) {
1448            break;
1449        }
1450        browser.scroll_collection_view(target).map_err(error)?;
1451        std::thread::sleep(Duration::from_secs(2));
1452        let mut next = browser
1453            .snapshot_for_profile(target, Some(source_profile))
1454            .map_err(error)?;
1455        if !next
1456            .get("url")
1457            .and_then(Value::as_str)
1458            .is_some_and(|current| same_profile_location(expected, current))
1459        {
1460            return Err(SocialError::AuthorizationRequired);
1461        }
1462        if let Some(posts) = next.get_mut("posts").and_then(Value::as_array_mut) {
1463            for post in posts {
1464                if let Some(previous) =
1465                    snapshot
1466                        .get("posts")
1467                        .and_then(Value::as_array)
1468                        .and_then(|posts| {
1469                            posts.iter().find(|previous| {
1470                                previous["text"] == post["text"]
1471                                    && previous["published_at"] == post["published_at"]
1472                                    && previous["author_url"] == post["author_url"]
1473                            })
1474                        })
1475                {
1476                    *post = previous.clone();
1477                }
1478            }
1479        }
1480        resolve_publications(browser, target, &mut next, date_budget);
1481        extract_posters(app, browser, target, &mut next, poster_budget);
1482        for (key, limit) in [
1483            ("links", 1000),
1484            ("structured", 30),
1485            ("posts", 40),
1486            ("post_resources", 4),
1487        ] {
1488            let additions = next
1489                .get(key)
1490                .and_then(Value::as_array)
1491                .cloned()
1492                .unwrap_or_default();
1493            if let Some(values) = snapshot.get_mut(key).and_then(Value::as_array_mut) {
1494                for value in additions {
1495                    if values.len() >= limit {
1496                        break;
1497                    }
1498                    if !values.contains(&value) {
1499                        values.push(value);
1500                    }
1501                }
1502            }
1503        }
1504    }
1505    Ok(snapshot)
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510    use super::*;
1511    /// Legacy content redaction is owner/provider/namespace scoped, preserves new snapshots and is idempotent in the generic store.
1512    #[test]
1513    #[cfg(feature = "sqlite")]
1514    fn legacy_snapshots_are_redacted_without_crossing_scope() {
1515        use pharos_runtime_database::{
1516            contract::{AppDataContract, migration_checksum},
1517            operations::{
1518                AppSchema, Migration, Mutation, Predicate, Query, SchemaChange, Transaction,
1519                WriteValue,
1520            },
1521        };
1522        let (_directory, app) = super::super::tests::application();
1523        let app_id = "generic_consumer";
1524        let schema = AppSchema {
1525            app_id: app_id.into(),
1526            entities: pharos_runtime_social::schema::entities(),
1527        };
1528        let changes = schema
1529            .entities
1530            .iter()
1531            .cloned()
1532            .map(SchemaChange::CreateEntity)
1533            .collect::<Vec<_>>();
1534        let contract = AppDataContract {
1535            schema,
1536            migrations: vec![Migration {
1537                app_id: app_id.into(),
1538                id: "create_social".into(),
1539                checksum: migration_checksum(app_id, "create_social", &changes).unwrap(),
1540                changes,
1541            }],
1542            legacy_adoption: None,
1543        };
1544        let config = pharos_runtime_database::DbBackendConfig {
1545            backend: "sqlite".into(),
1546            sqlite_path: Some(
1547                Path::new(app.app_root.as_ref().unwrap())
1548                    .parent()
1549                    .unwrap()
1550                    .join("observations.sqlite3"),
1551            ),
1552            ..Default::default()
1553        };
1554        let adapter = pharos_runtime_database_adapters::resolve(&config).unwrap();
1555        adapter.ensure_database(&config, &contract.schema).unwrap();
1556        adapter.apply_contract(&config, &contract).unwrap();
1557        let store = super::super::super::database::LogicalAppStore {
1558            config,
1559            schema: contract.schema,
1560            adapter,
1561        };
1562        let legacy = "Partial browser view; public evidence required before publication. private-account-panel";
1563        let mut inserts = Vec::new();
1564        for (post, owner, namespace, provider, text) in [
1565            ("old", 7, "deployment", "browser_facebook", legacy),
1566            (
1567                "region-only",
1568                7,
1569                "deployment",
1570                "browser_facebook",
1571                "Scoped browser content v2; public evidence required before publication. private-account-panel",
1572            ),
1573            ("other-owner", 8, "deployment", "browser_facebook", legacy),
1574            ("other-namespace", 7, "another", "browser_facebook", legacy),
1575            ("api", 7, "deployment", "facebook", legacy),
1576            (
1577                "new",
1578                7,
1579                "deployment",
1580                "browser_facebook",
1581                "Attributed browser posts v3; Public event",
1582            ),
1583        ] {
1584            let row = json!({"application":app_id,"namespace":namespace,"owner":owner,"provider":provider,"target":"profile","post_id":post,"published_at":"","permalink":"https://facebook.com/artist","text":text,"links":"[]"});
1585            inserts.push(Mutation::Insert {
1586                entity: "social_observation".into(),
1587                values: row
1588                    .as_object()
1589                    .unwrap()
1590                    .iter()
1591                    .map(|(key, value)| (key.clone(), WriteValue::Literal(value.clone())))
1592                    .collect(),
1593            });
1594        }
1595        store
1596            .adapter
1597            .transact(
1598                &store.config,
1599                &store.schema,
1600                &Transaction {
1601                    steps: inserts,
1602                    idempotency_key: None,
1603                },
1604            )
1605            .unwrap();
1606        let scope = ConnectionScope::new(app_id, "deployment", 7).unwrap();
1607        let steps =
1608            legacy_observation_redactions(&store, &scope, SocialProvider::Facebook).unwrap();
1609        assert_eq!(steps.len(), 2);
1610        store
1611            .adapter
1612            .transact(
1613                &store.config,
1614                &store.schema,
1615                &Transaction {
1616                    steps,
1617                    idempotency_key: None,
1618                },
1619            )
1620            .unwrap();
1621        let rows = store
1622            .adapter
1623            .query(
1624                &store.config,
1625                &store.schema,
1626                &Query {
1627                    entity: "social_observation".into(),
1628                    fields: vec!["post_id".into(), "text".into()],
1629                    filter: Some(Predicate::Contains(
1630                        "text".into(),
1631                        "private-account-panel".into(),
1632                    )),
1633                    order: vec![],
1634                    limit: 10,
1635                    offset: 0,
1636                },
1637            )
1638            .unwrap();
1639        assert_eq!(rows.len(), 3);
1640        assert!(rows.iter().all(|row| row["post_id"] != "old"));
1641        assert!(
1642            legacy_observation_redactions(&store, &scope, SocialProvider::Facebook)
1643                .unwrap()
1644                .is_empty()
1645        );
1646    }
1647
1648    /// Exercises real poster OCR with the serving installation configuration and explicit local test input.
1649    #[test]
1650    #[ignore = "requires PHAROS_TEST_POSTER_INPUT, PHAROS_TEST_RUNTIME_CONFIG and installed OCR helper/models"]
1651    fn poster_dates_use_configured_platform_helper() {
1652        let input = PathBuf::from(std::env::var("PHAROS_TEST_POSTER_INPUT").unwrap());
1653        let conf = PathBuf::from(std::env::var("PHAROS_TEST_RUNTIME_CONFIG").unwrap());
1654        let text = super::super::super::agent_media::extract_poster_with_platform_helper(
1655            &input,
1656            input.parent().unwrap(),
1657            &conf,
1658        )
1659        .unwrap();
1660        assert!(text.contains("OCTOBER 30 2026"));
1661        let candidate = json!({"evidence_kind":"rendered_post","author_url":"https://facebook.com/artist","source_profile":"https://facebook.com/artist","datePublished":"2026-10-22T10:00:00Z","description":"Festival details on poster","image_text":text});
1662        assert!(
1663            super::super::super::event_discovery::social_evidence::eligible(
1664                &candidate,
1665                super::super::super::runtime_timestamp_seconds("2026-10-23T12:00:00Z").unwrap()
1666            )
1667        );
1668    }
1669
1670    /// Artist redirects cannot silently collect the connected owner's home stream.
1671    #[test]
1672    fn profile_redirects_do_not_change_evidence_identity() {
1673        assert!(same_profile_location(
1674            "https://www.instagram.com/p/AbC123/",
1675            "https://www.instagram.com/madmaxxmusic/p/AbC123/"
1676        ));
1677        assert!(!same_profile_location(
1678            "https://www.instagram.com/p/AbC123/",
1679            "https://www.instagram.com/madmaxxmusic/p/Other123/"
1680        ));
1681        assert!(!same_profile_location(
1682            "https://www.instagram.com/p/AbC123/",
1683            "https://www.instagram.com/"
1684        ));
1685        assert!(!same_profile_location(
1686            "https://www.instagram.com/madmaxxmusic/",
1687            "https://www.instagram.com/p/AbC123/"
1688        ));
1689
1690        assert!(same_profile_location(
1691            "https://www.facebook.com/artist/",
1692            "https://m.facebook.com/artist"
1693        ));
1694        assert!(same_profile_location(
1695            "https://facebook.com/story.php?id=1&story_fbid=123",
1696            "https://www.facebook.com/story.php?id=1&story_fbid=123"
1697        ));
1698        assert!(!same_profile_location(
1699            "https://facebook.com/story.php?id=1&story_fbid=123",
1700            "https://facebook.com/story.php?id=1&story_fbid=456"
1701        ));
1702        assert!(!same_profile_location(
1703            "https://facebook.com/artist",
1704            "https://facebook.com/"
1705        ));
1706        assert!(!same_profile_location(
1707            "https://facebook.com/artist",
1708            "https://facebook.com/owner"
1709        ));
1710        assert!(!same_profile_location(
1711            "https://facebook.com/profile.php?id=1",
1712            "https://facebook.com/profile.php?id=2"
1713        ));
1714        assert!(!same_profile_location(
1715            "https://facebook.com/artist",
1716            "https://facebook.com.evil/artist"
1717        ));
1718    }
1719
1720    /// Observed event navigation cannot expand to other profiles, arbitrary redirects or private destinations.
1721    #[test]
1722    fn observed_facebook_events_are_bounded() {
1723        assert_eq!(
1724            facebook_event_url("https://www.facebook.com/events/123/?ref=profile").as_deref(),
1725            Some("https://www.facebook.com/events/123/")
1726        );
1727        for value in [
1728            "https://www.facebook.com/other-profile",
1729            "https://www.facebook.com/events/123/edit/",
1730            "https://www.facebook.com/events/not-an-id/",
1731            "https://facebook.com.evil.test/events/123/",
1732            "https://user:secret@www.facebook.com/events/123/",
1733            "http://www.facebook.com/events/123/",
1734        ] {
1735            assert!(facebook_event_url(value).is_none());
1736        }
1737    }
1738    /// Stable profile roots retain keys across reopening and cannot cross owner, app, deployment or provider.
1739    #[test]
1740    fn profiles_persist_and_remain_scope_bound() {
1741        let (_directory, app) = super::super::tests::application();
1742        let scope = ConnectionScope::new("generic_consumer", "deployment", 7).unwrap();
1743        let first = profile(&app, &scope, SocialProvider::Facebook).unwrap();
1744        stdfs::create_dir_all(&first).unwrap();
1745        stdfs::write(first.join("retention-marker"), "controlled-test-marker").unwrap();
1746        assert_eq!(
1747            profile(&app, &scope, SocialProvider::Facebook).unwrap(),
1748            first
1749        );
1750        assert!(first.join("retention-marker").is_file());
1751        for other in [
1752            ConnectionScope::new("generic_consumer", "deployment", 8).unwrap(),
1753            ConnectionScope::new("generic_consumer", "other", 7).unwrap(),
1754            ConnectionScope::new("another_app", "deployment", 7).unwrap(),
1755        ] {
1756            assert_ne!(
1757                profile(&app, &other, SocialProvider::Facebook).unwrap(),
1758                first
1759            );
1760        }
1761        assert_ne!(profile(&app, &scope, SocialProvider::X).unwrap(), first);
1762        assert_eq!(executable(&app), Err(SocialError::UnsupportedProvider));
1763    }
1764    /// Reconnection keeps rotated persistent cookies and refuses to cross into another retained account.
1765    #[test]
1766    #[ignore = "requires explicitly selected installed Chromium"]
1767    fn reconnect_preserves_newer_profile_cookies() {
1768        let (_directory, app) = super::super::tests::application();
1769        let executable = std::env::var("PHAROS_BROWSER_TEST_EXECUTABLE").unwrap();
1770        let profile = Path::new(app.app_root.as_ref().unwrap()).join("test-browser-profile");
1771        let mut browser = Browser::launch(
1772            Path::new(&executable),
1773            &profile,
1774            sessions::domains(SocialProvider::Facebook),
1775            false,
1776        )
1777        .unwrap();
1778        assert_eq!(
1779            restore_retained_session(&mut browser, SocialProvider::Facebook, "123", ""),
1780            Err(SocialError::AuthorizationRequired)
1781        );
1782        let older = r#"[{"name":"c_user","value":"123","domain":".facebook.com","path":"/","secure":true},{"name":"xs","value":"older-test-session","domain":".facebook.com","path":"/","secure":true}]"#;
1783        restore_retained_session(&mut browser, SocialProvider::Facebook, "123", older).unwrap();
1784        let newer = older.replace("older-test-session", "rotated-test-session");
1785        browser.restore(&newer).unwrap();
1786        restore_retained_session(&mut browser, SocialProvider::Facebook, "123", older).unwrap();
1787        assert!(browser.cookies().unwrap().contains("rotated-test-session"));
1788        restore_retained_session(&mut browser, SocialProvider::Facebook, "123", "").unwrap();
1789        assert_eq!(
1790            restore_retained_session(&mut browser, SocialProvider::Facebook, "456", older),
1791            Err(SocialError::Forbidden)
1792        );
1793        assert!(browser.cookies().unwrap().contains("rotated-test-session"));
1794        drop(browser);
1795    }
1796}