| """connectors_meta.py β the Meta Ads (Marketing API) connector: identity, credentials, and the |
| schema PROBE that W31-T40 measures with. |
| |
| β WHY A PROBE AND NOT A FIELD LIST. Owner ruling R8: the token exists to *"measure the schema now, |
| with nobody logging in via Facebook yet"*, and R2 requires the spawned grids to carry **every field |
| the API returns** β with T40's measured list as T47's only oracle (absent 0 / extra 0). Meta's own |
| Insights documentation declines to enumerate its fields, so a docs-derived list is not an answer to |
| either ruling. This file asks the API what it has. |
| |
| β THE MEASUREMENT IS `?metadata=1`, WHICH IS GRAPH'S OWN INTROSPECTION, and that is the whole |
| design. Requesting a node without `fields=` returns Meta's DEFAULT subset β a fraction of the |
| schema, and a list that changes when they change their defaults. `metadata=1` returns the node's |
| FIELD TABLE (name + type + description) whether or not this account populates it, so the answer is |
| a property of the API rather than of our ad account's data. Insights has no such table, so its |
| catalog is measured the only other honest way: request a candidate set and record which names the |
| API ACCEPTS and which it REJECTS BY NAME. Both halves are written to the asset, so a reader can see |
| the question as well as the answer. |
| |
| β SECRETS. `META_ADS_ACCESS_TOKEN` lives in gitignored `platform/.env`. It is read here, sent as a |
| POST-shaped query parameter to Graph over TLS, and **never printed, never logged, never written to |
| the asset** β the asset records the URL PATH and the field names, not the query string. Every call |
| is a read-only GET. |
| |
| python aios-web/api/connectors_meta.py --probe # writes the wave asset |
| python aios-web/api/connectors_meta.py --check # credentials only, no schema walk |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import os |
| import sys |
| import time |
| import urllib.error |
| import urllib.parse |
| import urllib.request |
| from pathlib import Path |
|
|
| |
| |
| |
| KEY = "meta_ads" |
| LABEL = "Meta Ads" |
|
|
| |
| |
| ENTRY_TYPE = "meta_ads" |
|
|
| |
| |
| |
| GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0" |
| GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}" |
|
|
| |
| |
| LEVELS = [ |
| ("adaccount", "Ad Account", None), |
| ("campaign", "Campaign", "campaigns"), |
| ("adset", "Ad Set", "adsets"), |
| ("ad", "Ad", "ads"), |
| ("adcreative", "Creative", "adcreatives"), |
| ] |
|
|
| |
| |
| |
| |
| |
| ENTITY_CANDIDATES = { |
| "adaccount": [ |
| "account_id", "account_status", "age", "amount_spent", "balance", "business_city", |
| "business_country_code", "business_name", "business_state", "business_street", |
| "business_street2", "business_zip", "capabilities", "created_time", "currency", |
| "disable_reason", "end_advertiser", "end_advertiser_name", "funding_source", |
| "has_migrated_permissions", "id", "io_number", "is_attribution_spec_system_default", |
| "is_direct_deals_enabled", "is_notifications_enabled", "is_personal", |
| "is_prepay_account", "is_tax_id_required", "line_numbers", "media_agency", |
| "min_campaign_group_spend_cap", "min_daily_budget", "name", |
| "offsite_pixels_tos_accepted", "owner", "partner", "spend_cap", "tax_id", |
| "tax_id_status", "tax_id_type", "timezone_id", "timezone_name", |
| "timezone_offset_hours_utc", "tos_accepted", "user_tasks", "user_tos_accepted"], |
| "campaign": [ |
| "account_id", "bid_strategy", "boosted_object_id", "budget_rebalance_flag", |
| "budget_remaining", "buying_type", "campaign_group_active_time", |
| "can_create_brand_lift_study", "can_use_spend_cap", "configured_status", "created_time", |
| "daily_budget", "effective_status", "id", "is_skadnetwork_attribution", "issues_info", |
| "last_budget_toggling_time", "lifetime_budget", "name", "objective", "pacing_type", |
| "primary_attribution", "promoted_object", "smart_promotion_type", "source_campaign", |
| "source_campaign_id", "special_ad_categories", "special_ad_category", |
| "special_ad_category_country", "spend_cap", "start_time", "status", "stop_time", |
| "topline_id", "updated_time"], |
| "adset": [ |
| "account_id", "adlabels", "adset_schedule", "asset_feed_id", "attribution_spec", |
| "bid_adjustments", "bid_amount", "bid_constraints", "bid_info", "bid_strategy", |
| "billing_event", "budget_remaining", "campaign", "campaign_active_time", |
| "campaign_attribution", "campaign_id", "configured_status", "created_time", |
| "creative_sequence", "daily_budget", "daily_min_spend_target", "daily_spend_cap", |
| "destination_type", "effective_status", "end_time", "frequency_control_specs", "id", |
| "instagram_actor_id", "is_dynamic_creative", "issues_info", "learning_stage_info", |
| "lifetime_budget", "lifetime_imps", "lifetime_min_spend_target", "lifetime_spend_cap", |
| "multi_optimization_goal_weight", "name", "optimization_goal", "optimization_sub_event", |
| "pacing_type", "promoted_object", "recurring_budget_semantics", "review_feedback", |
| "rf_prediction_id", "source_adset", "source_adset_id", "start_time", "status", |
| "targeting", "targeting_optimization_types", "time_based_ad_rotation_id_blocks", |
| "time_based_ad_rotation_intervals", "updated_time", "use_new_app_click"], |
| "ad": [ |
| "account_id", "ad_active_time", "ad_review_feedback", "ad_schedule_end_time", |
| "ad_schedule_start_time", "adlabels", "adset", "adset_id", "bid_amount", "bid_info", |
| "bid_type", "campaign", "campaign_id", "configured_status", "conversion_domain", |
| "created_time", "creative", "demolink_hash", "display_sequence", "effective_status", |
| "engagement_audience", "failed_delivery_checks", "id", "issues_info", |
| "last_updated_by_app_id", "name", "preview_shareable_link", "priority", |
| "recommendations", "source_ad", "source_ad_id", "status", "targeting", |
| "tracking_and_conversion_with_defaults", "tracking_specs", "updated_time"], |
| "adcreative": [ |
| "account_id", "actor_id", "adlabels", "applink_treatment", "asset_feed_spec", |
| "authorization_category", "body", "branded_content_sponsor_page_id", "bundle_folder_id", |
| "call_to_action_type", "categorization_criteria", "category_media_source", |
| "collaborative_ads_lsb_image_bank_id", "degrees_of_freedom_spec", "destination_set_id", |
| "dynamic_ad_voice", "effective_authorization_category", "effective_instagram_media_id", |
| "effective_object_story_id", "enable_direct_install", "enable_launch_instant_app", "id", |
| "image_crops", "image_hash", "image_url", "instagram_actor_id", |
| "instagram_permalink_url", "instagram_story_id", "instagram_user_id", |
| "interactive_components_spec", "link_deep_link_url", "link_destination_display_url", |
| "link_og_id", "link_url", "messenger_sponsored_message", "name", "object_id", |
| "object_store_url", "object_story_id", "object_story_spec", "object_type", "object_url", |
| "page_link", "page_message", "place_page_set_id", "platform_customizations", |
| "playable_asset_id", "portrait_customizations", "product_set_id", "recommender_settings", |
| "source_instagram_media_id", "status", "template_url", "template_url_spec", |
| "thumbnail_id", "thumbnail_url", "title", "url_tags", "use_page_actor_override", |
| "video_id"], |
| } |
|
|
| |
| |
| |
| |
| INSIGHTS_CANDIDATES = [ |
| "account_id", "account_name", "account_currency", "campaign_id", "campaign_name", |
| "adset_id", "adset_name", "ad_id", "ad_name", "date_start", "date_stop", |
| "impressions", "reach", "frequency", "clicks", "unique_clicks", "ctr", "unique_ctr", |
| "cpc", "cpm", "cpp", "spend", "social_spend", "actions", "action_values", |
| "conversions", "conversion_values", "cost_per_action_type", "cost_per_unique_click", |
| "cost_per_inline_link_click", "cost_per_thruplay", "purchase_roas", "website_purchase_roas", |
| "inline_link_clicks", "inline_link_click_ctr", "inline_post_engagement", |
| "outbound_clicks", "outbound_clicks_ctr", "unique_outbound_clicks", |
| "video_play_actions", "video_p25_watched_actions", "video_p50_watched_actions", |
| "video_p75_watched_actions", "video_p95_watched_actions", "video_p100_watched_actions", |
| "video_avg_time_watched_actions", "video_thruplay_watched_actions", |
| "quality_ranking", "engagement_rate_ranking", "conversion_rate_ranking", |
| "objective", "optimization_goal", "buying_type", "attribution_setting", |
| "canvas_avg_view_time", "estimated_ad_recallers", "full_view_impressions", |
| ] |
|
|
| |
| |
| |
| CALLS: list = [] |
|
|
|
|
| class MetaError(RuntimeError): |
| """A Graph refusal, carrying Meta's own message. β Its `str()` is safe to print: the token is |
| stripped from every URL before it reaches here.""" |
|
|
|
|
| def _token(): |
| """The Meta token from the environment, or from gitignored `platform/.env`. Returns "" when |
| absent β the caller REPORTS that rather than crashing, because "no token" is a real state of |
| this deployment and it is the state as of 2026-08-12.""" |
| tok = os.environ.get("META_ADS_ACCESS_TOKEN") or "" |
| if tok: |
| return tok.strip() |
| env = Path(__file__).resolve().parents[2] / "platform" / ".env" |
| if env.exists(): |
| for line in env.read_text(encoding="utf-8", errors="replace").splitlines(): |
| if line.strip().startswith("META_ADS_ACCESS_TOKEN"): |
| _, _, v = line.partition("=") |
| return v.strip().strip('"').strip("'") |
| return "" |
|
|
|
|
| def _safe(url): |
| """`url` with every query value redacted β the only form allowed near a log or an asset.""" |
| parts = urllib.parse.urlsplit(url) |
| q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True) |
| shown = "&".join(f"{k}=<redacted>" if k == "access_token" else f"{k}={v}" for k, v in q) |
| return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, shown, "")) |
|
|
|
|
| def get(path, token, **params): |
| """One read-only Graph GET. Returns the decoded body, or raises `MetaError` with Meta's reason. |
| |
| β A 400 FROM GRAPH IS AN ANSWER, NOT A CRASH β an unknown field name comes back as a 400 whose |
| message NAMES the field, and that is precisely how the Insights catalog is measured below. |
| """ |
| params["access_token"] = token |
| url = f"{GRAPH}/{path.lstrip('/')}?" + urllib.parse.urlencode(params) |
| CALLS.append({"path": path.lstrip("/"), "params": sorted(k for k in params |
| if k != "access_token")}) |
| req = urllib.request.Request(url, headers={"User-Agent": "aios-meta-probe/1"}) |
| try: |
| with urllib.request.urlopen(req, timeout=60) as r: |
| return json.loads(r.read().decode("utf-8", "replace")) |
| except urllib.error.HTTPError as e: |
| body = e.read().decode("utf-8", "replace") |
| try: |
| err = (json.loads(body).get("error") or {}) |
| msg = err.get("message") or body[:300] |
| code = err.get("code") |
| except Exception: |
| msg, code = body[:300], None |
| raise MetaError(f"HTTP {e.code} (code {code}) on {_safe(url)}: {msg}") from None |
| except Exception as e: |
| raise MetaError(f"{type(e).__name__} on {_safe(url)}: {e}") from None |
|
|
|
|
| def node_fields(node_id, token, candidates, batch=60): |
| """`(accepted, rejected, gated)` for ONE node, measured by ACCEPTANCE + BISECT. |
| |
| ββ `?metadata=1` IS DEAD AND THAT WAS MEASURED, NOT ASSUMED. This function used to ask Graph |
| for its published introspection table, which is the documented way to enumerate a node's |
| fields. On **v21.0 it returns nothing**: `GET /act_β¦?metadata=1` answers with keys |
| `['account_id','id']` and `GET /<campaign_id>?metadata=1` with `['id']` β `metadata.fields` is |
| EMPTY in both. The probe reported "0 fields" for all five levels and looked like a broken |
| probe; it was a withdrawn API. So the entity levels are measured exactly as Insights already |
| was: offer names, keep what the API takes. |
| |
| ββ **ACCEPTED IS NOT RETURNED, AND THIS IS THE ONE THAT BREAKS R2 SILENTLY.** A 20-field batch |
| on a live campaign answers **HTTP 200 with 14 keys** β Graph omits nulls from the response. A |
| catalog built from the keys that came BACK loses roughly a third of the schema, which is |
| R2's *"do not drop any column"* violated with no gate anywhere going red. The catalog is |
| therefore what was **ASKED and ACCEPTED**, and this function never inspects the payload's keys. |
| |
| β **A 400 NAMES THE OFFENDER; A 403 NAMES NOTHING.** `(#100) Tried accessing nonexisting |
| field (x)` tells us exactly which name to drop, so a batch converges in one retry. But the Ad |
| Account level answers `(#200) Requires business_management permission` β no field named β and |
| dropping the batch there reports "Ad Account: 0 fields", which is the honest-looking wrong |
| answer. An unnamed refusal is BISECTED instead: 11 calls classified all 46, finding exactly one |
| gated column (`owner`). |
| """ |
| accepted, rejected, gated = [], {}, {} |
|
|
| def ask(names): |
| """`(ok, err)` β one batch. `ok` means every name in it is a real, readable field.""" |
| try: |
| get(str(node_id), token, fields=",".join(names)) |
| return True, "" |
| except MetaError as e: |
| return False, str(e) |
|
|
| def resolve(names, depth=0): |
| if not names: |
| return |
| ok, err = ask(names) |
| if ok: |
| accepted.extend(names) |
| return |
| named = [f for f in names if f"({f})" in err or f"field ({f})" in err] |
| if named and len(named) < len(names): |
| for f in named: |
| rejected[f] = err[:200] |
| resolve([f for f in names if f not in named], depth + 1) |
| return |
| if len(names) == 1: |
| |
| (rejected if "(#100)" in err else gated)[names[0]] = err[:200] |
| return |
| half = len(names) // 2 |
| resolve(names[:half], depth + 1) |
| resolve(names[half:], depth + 1) |
| time.sleep(0.15) |
|
|
| for start in range(0, len(candidates), batch): |
| resolve(list(candidates[start:start + batch])) |
| return sorted(set(accepted)), rejected, gated |
|
|
|
|
| def insights_catalog(act_id, token, candidates=None): |
| """`(accepted, rejected)` for the daily Insights field catalog, measured by ACCEPTANCE. |
| |
| Asks in batches; on a 400 that names a field, drops that name and retries. What survives is |
| what the API takes at `time_increment=1` β which is the only claim R2's Insights grids need. |
| β `rejected` is reported too: "we asked and Meta refused" and "we never asked" are different |
| facts, and only the asset can keep them apart. |
| """ |
| remaining = list(candidates if candidates is not None else INSIGHTS_CANDIDATES) |
| rejected, accepted = {}, [] |
| for start in range(0, len(remaining), 25): |
| batch = remaining[start:start + 25] |
| while batch: |
| try: |
| get(f"act_{act_id}/insights", token, fields=",".join(batch), |
| time_increment="1", date_preset="last_7d", limit="1") |
| accepted.extend(batch) |
| break |
| except MetaError as e: |
| bad = [f for f in batch if f in str(e)] |
| if not bad: |
| rejected["(batch)"] = str(e) |
| break |
| for f in bad: |
| rejected[f] = str(e)[:200] |
| batch = [f for f in batch if f not in bad] |
| time.sleep(0.2) |
| return sorted(set(accepted)), rejected |
|
|
|
|
| def probe(): |
| """Walk the whole schema and return the report dict. Never raises on a Meta refusal β a |
| refusal IS the finding, and a probe that dies on one produces no asset at all.""" |
| token = _token() |
| report = {"graphVersion": GRAPH_VERSION, "token": bool(token), "accounts": [], |
| "levels": {}, "insights": {}, "errors": []} |
| if not token: |
| report["errors"].append( |
| "META_ADS_ACCESS_TOKEN is not set in the environment or in platform/.env. " |
| "Nothing was measured. This is the state of this box as of 2026-08-12: the PRD " |
| "records the token as 'recorded in platform/.env' and it is not there.") |
| return report |
| try: |
| me = get("me/adaccounts", token, |
| fields="id,account_id,name,account_status,currency,timezone_name,business_name") |
| report["accounts"] = me.get("data") or [] |
| except MetaError as e: |
| report["errors"].append(f"reaching /me/adaccounts: {e}") |
| return report |
| if not report["accounts"]: |
| report["errors"].append("the token authenticates but reaches ZERO ad accounts β a " |
| "permissions/scope answer, not an empty business") |
| return report |
| act = report["accounts"][0] |
| act_id = str(act.get("account_id") or str(act.get("id") or "").replace("act_", "")) |
| node_of = {"adaccount": f"act_{act_id}"} |
| for key, _label, edge in LEVELS: |
| if edge is None: |
| continue |
| try: |
| got = get(f"act_{act_id}/{edge}", token, fields="id", limit="1") |
| rows = got.get("data") or [] |
| if rows: |
| node_of[key] = rows[0]["id"] |
| else: |
| report["errors"].append(f"{key}: this account has no {edge}, so no real row could " |
| f"be read (the field TABLE is still measured below)") |
| except MetaError as e: |
| report["errors"].append(f"listing {edge}: {e}") |
| for key, label, _edge in LEVELS: |
| nid = node_of.get(key) |
| cands = ENTITY_CANDIDATES.get(key) or [] |
| if not nid: |
| report["levels"][key] = {"label": label, "accepted": [], "rejected": {}, "gated": {}, |
| "asked": len(cands), "node": "", |
| "note": "no node of this level was reachable, so nothing " |
| "could be offered to the API"} |
| continue |
| try: |
| accepted, rejected, gated = node_fields(nid, token, cands) |
| report["levels"][key] = {"label": label, "node": str(nid), "asked": len(cands), |
| "accepted": accepted, "rejected": rejected, "gated": gated} |
| except MetaError as e: |
| report["levels"][key] = {"label": label, "node": str(nid), "asked": len(cands), |
| "accepted": [], "rejected": {}, "gated": {}, |
| "note": str(e)} |
| try: |
| accepted, rejected = insights_catalog(act_id, token) |
| report["insights"] = {"accepted": accepted, "rejected": rejected, |
| "candidatesAsked": len(INSIGHTS_CANDIDATES)} |
| except MetaError as e: |
| report["errors"].append(f"insights catalog: {e}") |
| return report |
|
|
|
|
| def as_markdown(report): |
| """The wave asset. β Field NAMES and TYPES only β no row values, no account identifiers beyond |
| the ad account name the owner already knows, and no query strings.""" |
| L = ["# W31-T40 β the Meta Ads schema, MEASURED", "", |
| f"Graph version: `{report['graphVersion']}` Β· token present: " |
| f"**{'yes' if report['token'] else 'NO'}** Β· probe calls: **{len(CALLS)}**", ""] |
| if report["errors"]: |
| L += ["## What could not be measured", ""] |
| L += [f"- {e}" for e in report["errors"]] + [""] |
| if not report["token"]: |
| L += ["β **NOTHING BELOW WAS MEASURED.** This asset exists so the absence is a recorded " |
| "fact rather than a missing file: the probe is built, runs, and reports honestly " |
| "that it has no credential. Re-run `python aios-web/api/connectors_meta.py --probe` " |
| "the moment a token lands and this file fills itself in.", ""] |
| return "\n".join(L) |
| L += ["## Reachable ad accounts", "", "| account_id | name | status | currency | timezone |", |
| "|---|---|---|---|---|"] |
| for a in report["accounts"]: |
| L.append(f"| `{a.get('account_id','')}` | {a.get('name','')} | " |
| f"{a.get('account_status','')} | {a.get('currency','')} | " |
| f"{a.get('timezone_name','')} |") |
| L.append("") |
| L += ["## Method β and why it is not `?metadata=1`", "", |
| "β **Graph v21.0 publishes no introspection table.** `GET /act_β¦?metadata=1` returns " |
| "keys `['account_id','id']` and `GET /<campaign_id>?metadata=1` returns `['id']` β " |
| "`metadata.fields` is EMPTY. The first run of this probe reported *0 fields* for all " |
| "five levels and looked like a broken probe; it was a withdrawn API.", "", |
| "β So every level below is measured by **ACCEPTANCE**: the names in " |
| "`connectors_meta.ENTITY_CANDIDATES` are offered, and what Graph takes is the catalog. " |
| "A `(#100)` refusal NAMES the offending field, so a batch converges in one retry; a " |
| "`(#200)` permission refusal names nothing, so the batch is **bisected** rather than " |
| "dropped β otherwise the Ad Account level reports a confident, wrong `0 fields`.", "", |
| "ββ **ACCEPTED IS NOT RETURNED.** A 20-field batch on a live campaign answers HTTP 200 " |
| "with 14 keys, because Graph omits nulls. Every list below is what was **asked and " |
| "accepted** β the response's own keys are never inspected. Building columns from " |
| "returned keys drops roughly a third of the schema, which is R2's *do not drop any " |
| "column* broken with nothing going red.", ""] |
| for key, label, _edge in LEVELS: |
| lv = report["levels"].get(key) or {} |
| fields = lv.get("accepted") or [] |
| L += [f"## {label} β **{len(fields)} accepted** of {lv.get('asked', 0)} asked", ""] |
| if lv.get("note"): |
| L += [f"β {lv['note']}", ""] |
| if fields: |
| L += ["```", ", ".join(fields), "```", ""] |
| if lv.get("rejected"): |
| L += ["**Not a field on this account** (refused BY NAME, recorded rather than " |
| "guessed at):", ""] |
| L += [f"- `{k}`" for k in sorted(lv["rejected"])] + [""] |
| if lv.get("gated"): |
| L += ["β **Permission-gated β a REPORTED limit, not a silent omission** (R6's second " |
| "sentence):", ""] |
| L += [f"- `{k}` β {v}" for k, v in sorted(lv["gated"].items())] + [""] |
| ins = report.get("insights") or {} |
| if ins: |
| L += [f"## Insights at `time_increment=1` β {len(ins.get('accepted') or [])} accepted of " |
| f"{ins.get('candidatesAsked', 0)} asked", "", |
| "β Insights publishes no `metadata=1` field table, so this catalog is measured by " |
| "ACCEPTANCE: every name below was offered to the API and taken. A name absent from " |
| "this file was **not asked**, which is not the same as unavailable.", "", |
| "```", ", ".join(ins.get("accepted") or []), "```", ""] |
| if ins.get("rejected"): |
| L += ["### Refused by name", ""] |
| L += [f"- `{k}` β {v}" for k, v in sorted((ins.get("rejected") or {}).items())] + [""] |
| return "\n".join(L) |
|
|
|
|
| def probe_credential(fields, timeout=8): |
| """`{ok, message}` for a stored `meta_ads` credential β the keychain's liveness probe. |
| |
| β IT LIVES HERE, IN THE API LAYER, AND IS REGISTERED INTO `core.keychain` RATHER THAN |
| IMPORTED BY IT. `core` never imports up (ARCHITECTURE.md), and the first draft of this arm |
| did `sys.path.insert` + `import connectors_meta` from inside `core/keychain.py` β an |
| inversion with no gate on it. `keychain.register_prober` is the slot, borrowed from |
| `datastore.set_paused_probe`, which exists for exactly this reason one layer down. |
| |
| β ZERO reachable ad accounts is reported as NOT ok, deliberately: a token that authenticates |
| but sees no account is a SCOPE answer β the single likeliest failure for a Marketing API |
| credential β and `ok: true` would hide the one thing the admin needs to fix. |
| """ |
| tok = str((fields or {}).get("access_token") or (fields or {}).get("token") |
| or (fields or {}).get("api_key") or "") |
| if not tok: |
| return {"ok": False, |
| "message": "store the token under `access_token` (or `token` / `api_key`)"} |
| try: |
| got = get("me/adaccounts", tok, fields="id", limit="1") |
| except MetaError as e: |
| return {"ok": False, "message": str(e)[:200]} |
| n = len(got.get("data") or []) |
| return {"ok": n > 0, |
| "message": (f"Meta answered β {n} ad account(s) reachable" if n else |
| "the token authenticates but reaches NO ad account: that is a " |
| "scope/permission answer, not an empty business")} |
|
|
|
|
| def register(): |
| """Fill the keychain's probe slot for this connector. Called at import by the API layer's |
| connector registry (`routes_connectors`), so any process that can serve the directory has |
| also registered the prober. Never raises: a probe slot that could not be filled costs a |
| liveness CHECK, never the ability to store a credential.""" |
| try: |
| import core.keychain as kc |
| return kc.register_prober(ENTRY_TYPE, probe_credential) |
| except Exception: |
| return False |
|
|
|
|
| def main(argv): |
| check_only = "--check" in argv |
| token = _token() |
| if check_only: |
| print(f"META token present: {'yes' if token else 'NO'} " |
| f"(never printed; length is not disclosed)") |
| if not token: |
| return 2 |
| try: |
| me = get("me/adaccounts", token, fields="id,name", limit="5") |
| print(f"authenticated; reachable ad accounts: {len(me.get('data') or [])}") |
| return 0 |
| except MetaError as e: |
| print(f"REFUSED: {e}") |
| return 1 |
| report = probe() |
| out = (Path(__file__).resolve().parents[2] / ".claude" / "wiki" / "research" / "waves" |
| / "wave31" / "proto" / "meta-ads-schema.md") |
| out.parent.mkdir(parents=True, exist_ok=True) |
| out.write_text(as_markdown(report), encoding="utf-8") |
| print(f"wrote {out}") |
| for c in CALLS: |
| print(f" probe: GET /{c['path']} [{', '.join(c['params'])}]") |
| return 0 if report["token"] and not report["errors"] else 2 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main(sys.argv[1:])) |
|
|