File size: 37,272 Bytes
c3e4cb4 618de96 c3e4cb4 609fb78 c3e4cb4 051f280 609fb78 c3e4cb4 609fb78 c3e4cb4 e1b3e71 c3e4cb4 051f280 c3e4cb4 051f280 609fb78 051f280 c3e4cb4 051f280 609fb78 051f280 609fb78 051f280 c3e4cb4 609fb78 051f280 609fb78 c3e4cb4 051f280 c3e4cb4 051f280 609fb78 051f280 c3e4cb4 609fb78 051f280 609fb78 051f280 609fb78 051f280 c3e4cb4 051f280 609fb78 051f280 609fb78 051f280 609fb78 051f280 609fb78 051f280 609fb78 051f280 c3e4cb4 051f280 c3e4cb4 051f280 c3e4cb4 051f280 609fb78 051f280 609fb78 051f280 609fb78 051f280 c3e4cb4 051f280 c3e4cb4 051f280 c3e4cb4 618de96 051f280 618de96 e1b3e71 051f280 c3e4cb4 e1b3e71 051f280 c3e4cb4 051f280 c3e4cb4 051f280 c3e4cb4 | 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 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 | """THE CONNECTORS DIRECTORY (wave 23, contract C11 / owner ruling R8).
β WHAT THE OWNER ASKED FOR, verbatim: *"Build a connector module where it stores everythingβ¦ BUT
if its not yet integrated make it faded grey or something so i can use it as a to-do list for
integrations."* So this endpoint deliberately answers with things we have NOT built. A directory
that listed only working connectors would be honest about each row and dishonest about the shape
of the product β the owner wants to see the map, including the empty parts.
β THE ROW SET IS COMPOSED HERE, ONCE, FROM THE REGISTRIES THAT ALREADY EXIST β the OAuth
provider registry (`oauth_connect.PROVIDERS`), the keychain's entry types, the automation source
registry, and the env-configured built-ins. It is NOT a hand-written list of cards. That matters
because the alternative rots in a specific way: a provider gets wired up somewhere and the
directory keeps calling it "planned" for a wave or two, which is exactly the misinformation this
surface exists to prevent. The only hand-written part is `PLANNED` β the things that have no
registry entry anywhere yet, which is precisely what "not yet integrated" means.
β STATE IS PER-READER. `connected` is a fact about THIS user for a per-user credential (OAuth
slots are per-user by R7) and about the tenant for a shared one (Odoo, the Scraper). A directory
that reported another user's Gmail as "connected" would be both wrong and a small disclosure.
"""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends
import oauth_connect
from deps import Session, require_session
#: β W32-T10 β IMPORTED, NEVER RE-DERIVED. "Does the environment serve THIS tenant's Odoo?" is
#: one question, and this module used to answer it with an unguarded `os.environ.get("ODOO_URL")`
#: while `routes_keychain` answered it correctly. Importing the correct one is the fix; writing a
#: second correct one here would leave the same two-copies shape that produced the defect.
from routes_keychain import env_odoo_available
# β THE PREFIX IS LOAD-BEARING AND IT WAS MISSING (found at the wave-23 close-out audit).
# This router declared a bare `APIRouter()`, so its one path was served at `/connectors/directory`
# while `ConnectorsPage.tsx` fetches `${API_V1}/connectors/directory` β the directory would have
# 404'd in production with every gate green. Every other router in this package self-declares
# `prefix="/api/v1"`; this was the only one that did not.
#
# β AND THE WIRING GATE COULD NOT SEE IT. `verify_wiring`'s W23-W4 row matches the SERVER side on
# the substring `connectors/directory` and the CLIENT side on `${API_V1}/connectors/directory` β
# both matched, both sides "wired", prefixes disagreeing. A cross-fence row that greps each side
# independently proves the two files mention the same path SEGMENT, never that they agree on the
# same URL. `verify_api` now asserts the prefix on every mounted router instead.
router = APIRouter(prefix="/api/v1")
#: The to-do list (R8). Order is the owner's reading order, not alphabetical: the things most
#: likely to come next first. `queue` mirrors the standing provider queue in wave22-split C5-A2
#: so the two cannot drift into disagreeing about what is coming.
PLANNED = [
# ββ WAVE 31 Β· R7 β SIX ROWS LEFT THIS LIST FOR `NANGO_PROVIDERS` BELOW, and that move is the
# ticket. `slack`, `airtable`, `outlook`, `quickbooks`, `xero` and `hubspot` are providers
# Nango can AUTHENTICATE today, so leaving them here as "not built yet" is the same
# misinformation this module's header warns about β one wave later and about a different
# mechanism. What they still cannot do is INGEST, and that is what their own cards now say.
# β WAVE 32 Β· ITEM 4 β FIVE, NOW. The Airtable row is deleted from the directory entirely
# (the reason is in the provider registry below). It is still NAMED in the sentence above
# because that sentence is the record of where those six rows WENT, and a record that quietly
# drops one of its own entries is worse than no record.
# β W32-T14 β THIS SENTENCE STOPPED BEING TRUE IN THE SAME CHANGE THAT HID THE GOOGLE CARD.
# It read *"the Google connection already exists"*, which was the one row on this page that
# made a claim about ANOTHER row β so R6 hiding that other row turned a to-do entry into a
# false statement. Exactly the rot this module's header names, arriving sideways.
# β THE THREE `google_*` ROWS THEMSELVES STAY. Item 12 is about the connectors that offered a
# button and answered `503 oauth_unavailable`; these are `planned` to-do entries with no door,
# no state and no promise β which is precisely the map R8 asked this page to be.
{"key": "google_sheets", "label": "Google Sheets", "kind": "oauth",
"desc": "Read a sheet as a database."},
{"key": "google_drive", "label": "Google Drive", "kind": "oauth",
"desc": "Attach files from Drive to records."},
{"key": "google_calendar", "label": "Google Calendar", "kind": "oauth",
"desc": "Turn dated records into calendar entries."},
# ββ WAVE 30 Β· R4 β TWO ROWS WERE DELETED HERE, AND THEY WERE MISINFORMATION, not clutter.
#
# {"key": "tiktok", "label": "TikTok", ... "The second social source beside Instagram."}
# {"key": "meta", "label": "WhatsApp and Instagram (Meta)", ... "Blocked on Meta business
# verification; Instagram data arrives through the Scraper connector"}
#
# β THE TIKTOK ROW SAID "not built yet" ABOUT SOMETHING THAT SHIPPED IN WAVE 29 β which is
# precisely the rot this module's header warns about: *"a provider gets wired up somewhere and
# the directory keeps calling it planned for a wave or two, which is exactly the
# misinformation this surface exists to prevent."* TikTok is a PLATFORM of the Scraper
# connector now (R3/R4), and it appears in the Scraper card's own drill below.
# β THE META ROW IS A DIFFERENT CASE and is deleted for a different reason: what it described
# (Instagram arriving another way) is now visible ON the Scraper card, so keeping it would put
# Instagram on this page twice, once as working and once as blocked. The Meta business
# verification blocker itself is not lost β it is D-86's subject and lives in
# `.claude/wiki/research/ads-connectors-owner-actions.md`, where an owner action belongs.
]
#: β WAVE 30 Β· R4 β THE SCRAPER'S PLATFORMS, and the whole shape of the ruling in one list.
#: The owner: *"the damn Tiktok and Instagram belongs in the same bucket β¦ Only when I click
#: 'Scraper' β¦ would I see the option to choose either Instagram OR TikTok."* So the directory
#: mirrors the automation menus exactly: ONE card for the capability, and the platforms are behind
#: it rather than beside it.
#: β These are not connectors and must never become rows: they have no state, no credential and
#: no manage door of their own β the Scraper's key is what is configured, and it configures both.
SCRAPER_PLATFORMS = [
{"key": "instagram", "label": "Instagram",
"desc": "Profiles, posts and comments, by search or by handle."},
{"key": "tiktok", "label": "TikTok",
"desc": "The same three, on TikTok's own schema."},
]
#: Token connectors an admin can wire up TODAY by pasting a key β no OAuth client, no app review.
TOKEN_CONNECTORS = [
{"key": "stripe", "label": "Stripe", "entry": "stripe",
"desc": "Payments and payouts against your customer records.",
"hint": "Create a restricted key with read access in the Stripe dashboard.",
"note": "Storing the key connects the account; reading its data lands in a later release."},
{"key": "shopify", "label": "Shopify", "entry": "shopify",
"desc": "Orders and products from a Shopify storefront.",
"hint": "Create a custom app in your store admin and copy its Admin API token.",
"note": "Storing the key connects the account; reading its data lands in a later release."},
# ββ WAVE 31 Β· R2/R8 β META ADS IS A TOKEN CONNECTOR, AND IT IS THE ONE NEW CARD THAT IS
# ACTIONABLE TODAY. R8 put the schema work on a pasted token deliberately β *"so that you can
# also explore the Schema, without any user logging into it with Facebook yet"* β and T41
# MEASURED that the delegated-OAuth door is not available anyway: the Nango account holds one
# integration and it is a GitHub sample, so there is no Facebook integration to request
# `ads_read` from. This row is therefore the real door and the Nango one is honest about not
# being ready. β Its `entry` must match `keychain.ENTRY_TYPES`, or storing 400s.
{"key": "meta_ads", "label": "Meta Ads", "entry": "meta_ads",
"desc": "Campaigns, ad sets, ads, creatives and daily performance from your Meta ad account.",
"hint": "Paste a Marketing API access token with `ads_read`; the connector verifies it "
"against your ad accounts before storing it.",
"note": "The token connects the account and is verified live. The spawned databases "
"(285 measured columns) land with the Meta grids."},
]
#: ββ WAVE 31 Β· R7 β THE NANGO BREADTH REGISTRY. Owner: *"for Nango, i want to explore as many
#: other potential connection as possible, such as slack etc."*
#:
#: β ADDING A PROVIDER HERE IS THE WHOLE FEATURE β one row, no code β which is the claim T43 has
#: to make demonstrable rather than assert. The row carries only what a CARD needs; the auth flow
#: is Nango's and is identical for every one of them.
#:
#: ββ R7's SECOND SENTENCE IS THE HARD PART AND IT IS ENFORCED IN `_nango_rows` BELOW: *"Nango
#: buys CONNECT, not INGEST β every lit provider states on its own card what it can do today, or
#: we ship a directory of buttons that lead nowhere."* MEASURED for T42: the owner's Nango account
#: holds **1 integration (`github-getting-started`) and 0 connections**, so there is no sync on it
#: to inspect and NO provider here has a data layer. Every card says so, in its own words, and
#: none of them offers a button.
NANGO_PROVIDERS = [
{"key": "slack", "label": "Slack", "provider": "slack",
"desc": "Send messages into a channel when an automation reaches a step."},
{"key": "hubspot", "label": "HubSpot", "provider": "hubspot",
"desc": "Deals and contacts beside the Odoo customer book."},
# ββ WAVE 32 Β· OWNER ITEM 4 / R3 β THE AIRTABLE ROW IS DELETED. It read:
#
# {"key": "airtable", "label": "Airtable", "provider": "airtable",
# "desc": "Import an existing base as a database."}
#
# The owner asked for it to go, and R3 says how: **de-brand, don't revoke.** So the ROW goes
# and the transport does not β Nango can still authenticate `airtable` if a future ruling
# wants it back, and nothing in `oauth_connect` changed.
# β THE REASON IS NOT THAT IT WAS BROKEN. This connector is the one competitor whose model
# this product is built against, and offering to import a base is a promise nobody here is
# ready to keep. Every DESIGN-PARITY comment naming Airtable stays exactly where it is
# (~30 of them across the grid and automation clients): those are how we know what good looks
# like, and deleting them would cost the reasoning while saving nothing a user can see.
{"key": "quickbooks", "label": "QuickBooks", "provider": "quickbooks",
"desc": "Accounting actuals beside the Odoo sales data."},
{"key": "xero", "label": "Xero", "provider": "xero",
"desc": "Accounting actuals for tenants who do not use QuickBooks."},
{"key": "outlook", "label": "Microsoft Outlook", "provider": "outlook",
"desc": "The Outlook half of the email trigger."},
]
#: ββ W31-T44 / OWNER ITEM 6b β WHICH `ut_*` PREFIXES BELONG TO A CONNECTOR (contract C2).
#:
#: B's `user_tables.register_connected_prefix` (W31-T25) replaced a hardcoded `ut_odoo_` literal
#: with a registered SET, and this is where the connector layer declares its half. It lives beside
#: the other connector registries deliberately: "which databases does this connector own" is the
#: same kind of fact as "what is this connector called", and putting it anywhere else would make
#: the answer to one question live in two files.
#:
#: β REGISTERING A PREFIX DOES NOT MAKE A TABLE CONNECTED, and B posted this as the trap: the
#: third leg of `is_connected` still demands `source == AUTOMATION_SOURCE` **and**
#: `recordMode == AUTOMATION_RECORD_MODE`. A person who names a database "Meta ads spend" gets a
#: `ut_meta_β¦` key too, and exempting it from `MAX_ROWS` on the strength of its NAME is the silent
#: hole the stamp closes. The spawn stamps; this only says which family may qualify.
#: β `ut_odoo_` is SEEDED in `user_tables` rather than registered here, also deliberately β the
#: convention leg has to answer in a worker process where nothing registered anything.
CONNECTED_PREFIXES = {
"odoo": "ut_odoo_",
"meta": "ut_meta_",
}
def _register_prefixes():
"""Declare `CONNECTED_PREFIXES` to the store layer. Called at import β this module is mounted
by `main.py`, so any process that can serve the directory has also made the declaration.
Never raises: a store layer that cannot take the declaration must not stop the API booting,
and the consequence of a missed registration is a row CAP (R6's subject), not a wrong answer.
"""
try:
import core.user_tables as ut
return ut.register_connected_prefix(*sorted(CONNECTED_PREFIXES.values()))
except Exception: # noqa: BLE001
return frozenset()
_register_prefixes()
def _register_probers():
"""Let each connector answer `keychain.test_entry` for its OWN type β the SLOT, filled from
the layer that knows how to ask.
β THIS DIRECTION IS THE POINT. `core.keychain` must not import a connector module: `core`
never imports up (ARCHITECTURE.md / CLAUDE.md), and the tempting shortcut β a `sys.path`
insert and an `import connectors_meta` from inside `core` β is an architecture inversion that
no gate was watching for. Same idiom as `datastore.set_paused_probe`, one layer down, for the
same reason.
Import-time and fail-quiet: an unfillable slot costs a liveness CHECK, never the ability to
store a credential and never the API's ability to boot.
"""
try:
import connectors_meta
return connectors_meta.register()
except Exception: # noqa: BLE001
return False
_register_probers()
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# ββ W31-T44 β EVERY WAY THIS DIRECTORY CAN COME BACK SHORT, ENUMERATED AND MADE VISIBLE.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#
# The owner named Connectors FIRST in item 6 ("Connectors and Automation module still disappears").
# `.shell-nav-connectors` is a static `<a>` and cannot be dropped from the rail, so if Connectors
# "disappears" the subject is THIS payload. Enumerated by reading the composer rather than by
# guessing, and the answer was sharper than expected:
#
# β THERE WAS EXACTLY ONE FAILURE MODE AND IT DROPPED **EVERY** ROW. `directory()` had no
# `try` anywhere. `oauth_connect.status()`, `import automation_engine` and `bd_ready()` are
# three unguarded calls into three other subsystems, and a raise in ANY of them turned the
# whole endpoint into a 500 β at which point `ConnectorsPage` renders one sentence and no
# cards. A page whose entire job is to be a map of the workspace showed nothing because one
# registry was unhappy.
# β AND ONE SILENT ONE, WHICH IS WORSE THAN THE LOUD ONE. `_entry_types` swallowed every
# exception into an empty set, so an unreadable or locked keychain rendered Odoo, Stripe and
# Shopify as **"available"** β i.e. *"you have not connected this"* about a connector the
# tenant HAS connected. Not short: wrong, and confidently.
#
# β SO THE COMPOSER IS A LIST OF CONTRIBUTIONS AND EACH ONE FAILS ALONE. A contribution that
# raises costs its own rows and NOTHING else, and says so in `incomplete` with a cause and a
# recommendation β R6's second sentence, applied to a page instead of to a row cap. Silence is the
# violation, not the limit.
#: The contributions, by name. β THIS LIST IS THE ENUMERATION the ticket asks for, and
#: `verify_meta` breaks each one IN TURN and requires (a) the other rows still render and (b) this
#: name appears in `incomplete` β so a contribution added later without a probe is a red, not a
#: quietly unprotected path.
CONTRIBUTORS = ("keychain", "odoo", "oauth", "token", "nango", "scraper", "builtin", "planned")
def _entry_types(rt, uname=""):
"""`(types, failure)` β which keychain entry types THIS READER has, and what stopped us.
β IT USED TO RETURN A BARE SET AND SWALLOW EVERYTHING, and that is the silent half of item 6b:
a locked keychain became an EMPTY set, which is indistinguishable from "this tenant has
connected nothing" β so the page said `available` about a live Odoo connection. Degrading to
"nothing connected" is only honest if the reader is told that is what happened.
β W32-T11 / R4 β AND IT IS THIS READER'S SET, NOT THE TENANT'S. The module header has always
said *"STATE IS PER-READER β¦ a directory that reported another user's Gmail as connected would
be both wrong and a small disclosure"*, and a personal keychain entry is exactly that case one
credential kind over. Reading `list_entries` straight would have lit a colleague's Stripe card
on your directory while the Settings pane next door correctly hid it β the same
two-answers-to-one-question shape W32-T10 just removed from the Odoo row.
β THE SECOND RETURN VALUE IS THE SCOPE PER TYPE, and BUSINESS WINS. A reader holding both a
business-wide Stripe key and a personal one is connected business-wide β the stronger fact is
the true one, and taking whichever row sorted first would make the card's label depend on an
id.
"""
try:
import routes_keychain as rk
rows = rk.visible_entries(rt, uname) or []
types, scopes = set(), {}
for e in rows:
t = str(e.get("type") or "")
types.add(t)
if scopes.get(t) != rk.DEFAULT_SCOPE:
scopes[t] = str(e.get("scope") or rk.DEFAULT_SCOPE)
return types, scopes, None
except Exception as e: # noqa: BLE001
return set(), {}, f"{type(e).__name__}: {e}"
def directory(rt, uname, is_admin=False, incomplete=None):
"""The composed row set. Pure over the session, so the gate drives it without a request.
`incomplete` is an optional list the caller lends to collect contribution failures; the route
passes one and puts it on the wire. Callers that do not care pass nothing and simply get the
rows that composed.
"""
rows = []
short = incomplete if incomplete is not None else []
def contribute(source, fn, why, fix):
"""Run one contribution. A failure costs ITS rows and records why β never the page."""
try:
fn()
except Exception as e: # noqa: BLE001
short.append({"source": source, "cause": f"{why} ({type(e).__name__}: {e})",
"recommendation": fix})
have, scopes, kc_failed = _entry_types(rt, uname)
if kc_failed:
short.append({
"source": "keychain",
"cause": f"this tenant's keychain could not be read ({kc_failed}), so a connector "
f"that IS connected through a stored credential is shown as available",
"recommendation": "unlock the keychain (AIOS_KEYCHAIN_KEY) and reload; the states "
"below are otherwise correct"})
# --- Odoo: the reference connector. Env credentials are tenant #0's only (the R3 rule), so
# a stored keychain entry is what makes it connected for anybody else.
#
# ββ W32-T10 / OWNER ITEM 6 β THIS ROW SPENT FOUR WAVES TELLING EVERY TENANT IT WAS
# CONNECTED TO ANOTHER COMPANY'S ERP. The state read `"odoo" in have or
# os.environ.get("ODOO_URL")`, and the second leg has no tenant in it at all: one Space
# process serves every tenant, so `ODOO_URL` being set for tenant #0 lit this card
# `connected` for nurilab, GTM Lab and everyone after them β and `manage: keychain` then
# offered "Manage keys" for a credential their workspace does not hold. **The comment
# directly above stated the rule the line broke**, which is the useful part of the lesson:
# the guard was written down and not written in.
# β THE OTHER HALF OF THE FIX IS THAT NOTHING HERE RE-DERIVES IT. `env_odoo_available` lives
# in `routes_keychain`, which asked this question CORRECTLY the whole time β so the repair is
# to delete this module's answer, not to write a third one
# ([[one-question-two-normalizers]]).
# β The `have` leg is UNTOUCHED on purpose: `list_entries` reads metadata even while the
# keychain is LOCKED, so a tenant with a stored Odoo entry stays `connected` when the
# platform key is missing. Routing that leg through a resolver that requires `unlocked()`
# would have flipped a real connection to "available" β the silent case W31-T44 exists to
# prevent, re-introduced by the fix for its sibling.
def _odoo():
rows.append({
"key": "odoo", "label": "Odoo", "kind": "builtin",
"desc": "Sales, invoices, products and customers from your Odoo ERP.",
"state": ("connected" if ("odoo" in have or env_odoo_available(rt))
else "available"),
# W32-T11 / C1 β always business: Odoo is what the whole workspace's databases are
# read through, which is why `routes_keychain.TENANT_WIDE_TYPES` refuses to make it
# personal in the first place.
"scope": scopes.get("odoo") or "business",
"manage": "keychain" if is_admin else "",
})
# β GUARDED EVEN THOUGH IT CANNOT RAISE TODAY β `os.environ.get` and a set membership are the
# whole of it. The guard is here because the enumeration is only worth something if it is
# EXHAUSTIVE: an unguarded block is an un-enumerated drop path, and "this one is obviously
# safe" is how the other five got written in the first place.
contribute("odoo", _odoo, "the Odoo connector row could not be composed",
"reload; every other row is unaffected")
# --- OAuth providers, straight off the registry (so a new entry appears here for free).
# β `status()` READS EVERY PER-USER CREDENTIAL SLOT, which means it touches the keychain and
# the store β the two things most likely to be unavailable. Unguarded, it took the whole page
# with it.
# ββ W32-T14 / OWNER ITEM 12 / R6 β `oauth_connect.offered()` FILTERS THIS CONTRIBUTION.
# The owner reported Google and Gmail as broken connectors: the card offered a Connect button
# and the button answered `503 oauth_unavailable` in raw JSON. R6 is *"hidden until they are
# real"*, so the row goes rather than the error being prettied up β the tombstone and the
# money reason (D-45, CASA verification at ~$540β1,800/yr) live on `offered()` itself.
# β THE REGISTRY IS STILL WALKED WHOLE. A registry-driven directory is the point of this
# module ("a new provider appears here for free"), so the filter is one predicate on the loop
# and NOT a deletion β the day the verification is paid for, one key flips and the row is
# back with its state, its `needs` and its brand mark intact.
def _oauth():
st = oauth_connect.status(rt, uname)
for slug, meta in oauth_connect.PROVIDERS.items():
if not oauth_connect.offered(slug):
continue
rows.append(_oauth_row(slug, meta, st.get(slug) or {}))
contribute("oauth", _oauth,
"the OAuth provider registry could not be read, so no OAuth connector is listed",
"the rest of the directory is complete; reload to retry the OAuth states")
# --- Token connectors (R8): connectable today, and honest that data flows arrive later.
def _tokens():
for t in TOKEN_CONNECTORS:
rows.append({
"key": t["key"], "label": t["label"], "kind": "token", "desc": t["desc"],
"hint": t["hint"],
"state": "connected" if t["entry"] in have else "available",
# β W32-T11 / C1 / R4 β the scope of the credential BEHIND this card, for THIS
# reader. A token connector is the case R4 is really about: a member's own Stripe
# key is theirs, and the card must say which kind of connection it is looking at.
"scope": scopes.get(t["entry"]) or "business",
# β `manage` STAYS ADMIN-ONLY HERE, deliberately, and it is a smaller door than
# R4 allows. A member may now hold a PERSONAL connection β the room for it is
# Settings β Keychains, which W32-T11 opens to them β but this card's action word
# has been "the tenant-wide credential door" since wave 23 and a sibling gate
# asserts a member is offered none. Widening it is a one-word change once that
# assertion is retargeted; booked rather than done, because reddening another
# lane's gate to add a second door to a room that is already reachable is a bad
# trade.
"manage": "keychain" if is_admin else "",
# Said once, on the row, rather than in a paragraph somewhere (R13): storing the
# key is real and useful, and it is not the same as the data being on screen.
"note": "Storing the key connects the account; reading its data lands in a later "
"release.",
})
contribute("token", _tokens,
"the token-connector registry could not be composed",
"Stripe and Shopify are missing from this list only; nothing else is affected")
def _nango():
rows.extend(_nango_rows())
# β W32-T13 / ITEM 3 β THE CONTRIBUTOR KEY STAYS `nango`, THE SENTENCES DO NOT. `source` is an
# internal id (it must match `CONTRIBUTORS`, and `verify_meta` breaks this contribution by
# name), and `DirectoryGaps` renders only `cause` and `recommendation` β so the key never
# reaches a screen while these two strings do. De-branding the id instead would have renamed
# a gate's subject to fix a string nobody sees.
contribute("nango", _nango,
"the sign-in provider registry could not be composed",
"the providers you sign in to are missing from this list; every other row is "
"unaffected")
def _scraper():
rows.append(_scraper_row())
contribute("scraper", _scraper,
"the Scraper connector's readiness could not be determined "
"(automation_engine did not answer)",
"the Scraper card is missing from this list; automations are unaffected")
def _builtin():
rows.append({
"key": "webhooks", "label": "Webhooks", "kind": "builtin",
"desc": "Let another system start an automation by calling a URL.",
"state": "connected", "manage": "automation",
})
contribute("builtin", _builtin, "a built-in connector row could not be composed",
"reload; the remaining rows are correct")
def _planned():
for p in PLANNED:
rows.append({**p, "state": "planned", "needs": "not built yet", "manage": ""})
contribute("planned", _planned, "the planned-connector list could not be composed",
"the to-do list is missing; every CONNECTED row above is still correct")
return rows
def _oauth_row(slug, meta, row_st):
"""One OAuth provider's card. Lifted out of `directory` whole so the composer reads as a list
of contributions; behaviour is byte-identical."""
return {
"key": slug, "label": meta.get("label") or slug.title(), "kind": "oauth",
"desc": "Gmail for the email trigger; the same connection unlocks Sheets, Drive "
"and Calendar later." if slug == "google" else "",
# THREE distinct states, and the distinction is the point: a provider nobody has
# configured on this deployment is not the same as one this user has not connected,
# and until now both rendered identically (the C11 status() gap).
"state": ("connected" if row_st.get("connected") and not row_st.get("reconnect")
else "reconnect" if row_st.get("reconnect")
else "available" if row_st.get("configured")
else "unconfigured"),
"connectedAs": row_st.get("email") or "",
"startUrl": f"/api/v1/oauth/{slug}/start",
"manage": "oauth",
"needs": "" if row_st.get("configured") else "the owner registers the client",
}
def _nango_rows():
"""One card per `NANGO_PROVIDERS` row, each stating what it can do TODAY (R7).
β NO NETWORK CALL. The obvious "honest" implementation asks Nango which integrations exist
and lights the ones that do β and that would put a third-party HTTP round trip on a page this
very wave is making faster, on a route that already had exactly one failure mode. The
deployment fact this page can answer CHEAPLY is whether the platform holds a Nango key at all,
which is the same class of check `bd_ready()` and `oauth_connect.configured()` already make.
β Whether a SPECIFIC integration is configured in the Nango dashboard is a different fact,
measured once in `proto/nango-verdict.md` (1 integration, and it is a GitHub sample), and it
is what `needs` names rather than pretends to have checked.
ββ EVERY ROW IS `available` AT BEST AND CARRIES NO `manage` DOOR, which is R7's second
sentence made structural: *a card whose button leads nowhere without saying so* fails the
ruling, so these cards have no button at all and say why in `needs` and `note`.
"""
have_key = bool((os.environ.get("NANGO_SECRET_KEY") or "").strip())
rows = []
for p in NANGO_PROVIDERS:
rows.append({
"key": p["key"], "label": p["label"], "kind": "oauth", "desc": p["desc"],
"provider": p["provider"],
"state": "available" if have_key else "unconfigured",
# ββ WAVE 32 Β· OWNER ITEM 3 / R3 β DE-BRANDED. These two sentences used to read:
#
# needs: "this provider's integration must be created in the Nango dashboard first"
# / "the owner registers NANGO_SECRET_KEY on this deployment"
# note: "Connects through Nango. Reading this provider's data into a database is
# not built yet β measured: no sync is configured on this account."
#
# β R3 IS "DE-BRAND, DON'T REVOKE": the transport is unchanged and still Nango's β
# `oauth_connect.nango_probe`, `NANGO_SECRET_KEY` and every constant keep their names,
# because that is what the person setting the secret has to type. What goes is the
# supplier's name on a CUSTOMER's screen. Which vendor carries an OAuth handshake is
# our problem, exactly as `AIOS_BRIGHTDATA_KEY` sits behind a card labelled "Scraper".
# β THE HONESTY THE SENTENCE CARRIED IS NOT LOST, which is the half a de-brand usually
# drops: R7's rule is that a lit provider states what it can do TODAY, so the note
# still says "not built yet" in as many words, and `needs` still names the remaining
# step β it just no longer names the company doing it.
"needs": ("this connection has not been set up for this provider yet"
if have_key else
"the owner finishes connection setup on this deployment"),
# β THE SENTENCE R7 DEMANDS, on the row rather than in a paragraph somewhere (R13).
"note": "Signing in works. Reading this provider's data into a database is not "
"built yet.",
"manage": "",
})
return rows
def _scraper_row():
"""The Scraper card. Its own function because `bd_ready()` reaches into `automation_engine` β
an import and a call into another subsystem, i.e. the single most likely thing in this file to
raise, and until W31-T44 it could take the whole page with it."""
import automation_engine as _eng
# β WAVE 25 Β· C2 / owner ruling R9 β THE ROW IS "SCRAPER", NOT THE VENDOR'S NAME.
#
# β THE KEY CHANGED TOO (`brightdata` -> `scraper`), which is the half worth being careful
# about: it is what the trigger picker's Connector sub-group is named after, so the two must
# agree. Grepped before flipping β NO client file switches on this key (the six client hits
# for "brightdata" are all `CaptureTier`, which is the VENDOR and is deliberately untouched),
# and the one server reader was `verify_automation`'s directory-key check, retargeted in the
# same change.
#
# β THE VENDOR IS REAL AND STAYS REAL. `AIOS_BRIGHTDATA_KEY`, `bd_ready()` and every `BD_*`
# constant are the supplier and keep their names β this renames what a CUSTOMER sees, because
# which supplier answers a scrape is our problem and not something to make them learn. The
# `needs` string still names the env var, deliberately: it is read by whoever has to set it.
ready = _eng.bd_ready()
return {
"key": "scraper", "label": "Scraper", "kind": "builtin",
# β WAVE 30 Β· R4 β the sentence names the CAPABILITY; the platforms are the drill below.
# It used to say "Instagram profile and post capture", which stopped being true the day
# TikTok discovery shipped and would have to be re-edited for every platform after it.
"desc": "Profile, post and comment capture for the discovery automations.",
"state": "connected" if ready else "unconfigured",
"needs": "" if ready else "AIOS_BRIGHTDATA_KEY is not set on this deployment",
"manage": "",
# β ONE CREDENTIAL, TWO PLATFORMS β which is why this is a field on the row and not two
# rows. `bd_ready()` above answers for both; a per-platform state would be a second
# answer to a question that has one.
"platforms": [dict(p) for p in SCRAPER_PLATFORMS],
}
@router.get("/connectors/directory")
def connectors_directory(session: Session = Depends(require_session)):
"""C11: every connector this platform knows about, with its state for THIS reader.
Session-gated rather than admin-gated on purpose: a non-admin should be able to see what the
workspace is connected to (and connect their OWN per-user accounts, which is the whole point
of R7's per-user slots). `manage` is what carries admin-ness β it is empty for a non-admin,
so the client renders the card without an action rather than offering a door that 403s.
"""
# β `session.admin`, NOT `is_admin`. A `getattr(session, "is_admin", False)` here reads as
# careful and is silently False for every admin on earth β the manage actions would simply
# never appear, with nothing to catch it. Attribute name checked against `deps.Session`.
# β W31-T44 β `incomplete` IS PART OF THE ANSWER, not an error channel. The page's job is to
# be a map of the workspace; a map missing a region must say which region, or the reader
# concludes the region does not exist. That is the owner's item 6b in one sentence, and it is
# R6's second sentence applied to a payload: a limit that cannot be removed is REPORTED with
# its cause and a recommended fix. `[]` on a healthy read, so the client can test it directly.
incomplete = []
rows = directory(session.runtime, session.uname, bool(session.admin), incomplete=incomplete)
return {"connectors": rows,
"incomplete": incomplete,
"counts": {"connected": sum(1 for r in rows if r["state"] == "connected"),
"available": sum(1 for r in rows if r["state"] == "available"),
"planned": sum(1 for r in rows if r["state"] == "planned")}}
|