| """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], | |
| } | |
| 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")}} | |