File size: 28,847 Bytes
051f280 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 | """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
#: The connector's identity in the platform's registries. β `ut_meta_` is declared ONCE, in
#: `routes_connectors.CONNECTED_PREFIXES`, and read from there β a second spelling here is the
#: [[one-question-two-normalizers]] shape and `verify_meta` asserts it does not exist.
KEY = "meta_ads"
LABEL = "Meta Ads"
#: The keychain entry type an admin stores a token under. β `core.keychain.ENTRY_TYPES` must learn
#: this or `add_entry` 400s β that is W31-T48's half, not this file's.
ENTRY_TYPE = "meta_ads"
#: β VERSIONED, AND THE VERSION IS REPORTED RATHER THAN ASSUMED. Graph deprecates versions on a
#: schedule; a pinned literal that has aged out fails with an error that reads like a bad token.
#: Overridable so a probe can be re-run against a newer version without an edit.
GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0"
GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"
#: The five entity levels R2 names, in the order a person reads them. `edge` is the connection on
#: its parent; `node` is what `metadata=1` is asked about.
LEVELS = [
("adaccount", "Ad Account", None),
("campaign", "Campaign", "campaigns"),
("adset", "Ad Set", "adsets"),
("ad", "Ad", "ads"),
("adcreative", "Creative", "adcreatives"),
]
#: β INPUTS, NOT ANSWERS β the names OFFERED to the API per entity level. The measurement is
#: which of them Graph ACCEPTS (see `node_fields`); a name missing from a list here is "not
#: asked", never "not available", and the asset says so on its own face.
#: β THESE EXIST BECAUSE `?metadata=1` RETURNS NOTHING ON v21.0 β measured, not assumed. Graph
#: withdrew the introspection table that used to make a candidate list unnecessary.
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"],
}
#: β AN INPUT, NOT AN ANSWER β and labelled as one in the asset. Insights has no `metadata=1`
#: table, so the catalog is measured by ACCEPTANCE: every name here is offered to the API and the
#: probe records which it takes and which it names in an error. A name missing from this list is
#: therefore "not asked", never "not available", and the asset says so.
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",
]
#: Every probe call, for the mailbox log the PRD's shared-resource table demands ("log every probe
#: in mailbox/D.md; the rate limit is shared across the token"). Paths only β never a query string,
#: because the token rides in one.
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: # noqa: BLE001
msg, code = body[:300], None
raise MetaError(f"HTTP {e.code} (code {code}) on {_safe(url)}: {msg}") from None
except Exception as e: # noqa: BLE001
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:
# a single name that still refuses: classify it by WHAT the refusal was.
(rejected if "(#100)" in err else gated)[names[0]] = err[:200]
return
half = len(names) // 2 # unnamed refusal β bisect, never drop
resolve(names[:half], depth + 1)
resolve(names[half:], depth + 1)
time.sleep(0.15) # the rate limit is shared across this token
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: # a refusal that names no field: report, do not loop
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) # the rate limit is shared across this token
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: # noqa: BLE001
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:]))
|