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