File size: 25,477 Bytes
37d92f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
37d92f0
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37d92f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
"""oauth_connect.py β€” the generic OAuth CONNECTOR framework (wave 22, contract C5 + A2 / R12).

WHAT THIS IS, and what it is deliberately not. This connects a data scope to a PERSON β€”
"read *my* Gmail" β€” which is a different animal from `core/auth_google.py` (LOGIN β€” proving who
you are). The two share one Google Cloud app (one client id, per R12) and nothing else: login
stays the untouched template it has been since wave 7, and this module never mints a session.

⭐ REGISTRY-DRIVEN (C5 amendment A2 β€” the SOURCES-registry law applied to auth). A provider is
declared ONCE in `PROVIDERS` β€” endpoints, scopes, env names, the authorize extras β€” and the
generic flow below reads the declaration. Google is the only entry THIS wave; Slack/Airtable/
Outlook/QuickBooks later are one entry + secrets each, zero new code paths. β›” Nango is the
STUDY reference for this shape and is Elastic-licensed: no line of `providers.yaml` is copied β€”
every entry here sources from the provider's own public documentation.

THE FOUR RULES, each load-bearing:

  1. **PER-USER SLOTS (R7, the QM seam).** A refresh token is identity, not infrastructure β€”
     it lands in `core.keychain`'s `user_secrets` sub-bucket keyed by USERNAME under the slot
     `oauth_<provider>`, Fernet at rest, invisible to the admin keychain table. An automation
     polls through its CREATOR's slot, nobody else's.
  2. **FAIL-CLOSED, never an error page.** No client id β‡’ the provider is not `configured` β‡’
     the email trigger reads `ready: false` and the connect door answers a sentence β€” the flow
     is simply not offered. Same in every failure direction: an expired/revoked refresh token
     becomes a LOUD `reconnect: true` on the status (Google's Testing-mode refresh tokens die
     every ~7 days β€” silence here would read as a trigger that just stopped).
  3. **PKCE + state, single-use, short-lived, user-bound.** The authorize URL carries
     S256(code_verifier) and an unguessable `state`; the callback consumes the state exactly
     once, checks it was minted for THIS user, and refuses anything older than ten minutes.
     The state also carries the RETURN PATH (A3: the user lands back where they left, never at
     the root) β€” relative paths only, so the callback can never be walked off-origin.
  4. **Tokens travel in headers/bodies to the provider's REGISTERED endpoints only** β€” never a
     URL derived from input β€” and nothing here logs one: errors quote a STATUS and a sentence,
     the `bd_call` discipline.
"""
from __future__ import annotations

import base64
import datetime as _dt
import hashlib
import json
import os
import re
import secrets
import threading
import time
from urllib.parse import urlencode

import requests

#: ⭐ THE PROVIDER REGISTRY (C5-A2). Everything the generic flow needs to speak one provider,
#: declared as data. `authorize_extra` is the per-provider consent dialect β€” Google's
#: `access_type=offline&prompt=consent` is what makes a REFRESH token arrive on every connect;
#: without it a re-connect answers access-only and the poll dies within the hour, which reads
#: exactly like a broken trigger.
PROVIDERS = {
    "google": {
        "label": "Google",
        "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
        "token_url": "https://oauth2.googleapis.com/token",
        "userinfo_url": "https://openidconnect.googleapis.com/v1/userinfo",
        "scopes": "openid email https://www.googleapis.com/auth/gmail.readonly",
        "authorize_extra": {"access_type": "offline", "prompt": "consent"},
        "env_id": "GOOGLE_OAUTH_CLIENT_ID",
        "env_secret": "GOOGLE_OAUTH_CLIENT_SECRET",
        # β›”β›” WAVE 32 Β· OWNER ITEM 12 / RULING R6 β€” NOT OFFERED. See `offered()` below.
        "offered": False,
    },
}


def offered(provider):
    """Does the PRODUCT offer this provider today? (W32-T14 / owner item 12 / R6.)

    β›”β›” THE TOMBSTONE, PUT WHERE THE NEXT READER WILL LOOK β€” *"why did Gmail disappear?"*
    The owner reported the Google and Gmail connectors as broken: the card offered a Connect
    button, and the button answered `503 oauth_unavailable` in raw JSON. R6: *"Google and Gmail
    are HIDDEN until they are real. Remove the rows from the connectors directory rather than
    shipping a door that returns `oauth_unavailable`. No spend, no verification."*
    ⚠ AND IT IS NOT A BUG TO GO AND FIX. The blocker is **D-45**: Google will not grant the
    `gmail.readonly` scope to a production app without CASA verification, which is a paid annual
    security assessment (measured at roughly **$540–1,800/yr**) plus a privacy policy on an owned
    domain. Until somebody decides to spend that, `GOOGLE_OAUTH_CLIENT_ID` is unset on every
    deployment and every honest state this flow can reach is "you cannot use this".

    β›” THE REGISTRY ENTRY STAYS, and that is deliberate: `status()` still walks it, so
    `routes_automation`'s email-trigger readiness keeps its answer instead of turning a
    provider-shaped question into a KeyError. What changes is that the DIRECTORY does not list it
    and the start/callback/disconnect doors 404 β€” the flow is not offered rather than offered and
    broken. Flip this one key to `True` the day the verification is paid for.

    Defaults to True: a provider added to the registry is offered unless it says otherwise, so
    this flag can never silently hide the NEXT connector somebody wires up.
    """
    return bool((provider_def(provider) or {}).get("offered", True))

GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1/users/me"
STATE_TTL_SECONDS = 600
HTTP_TIMEOUT = 12.0


def _kc():
    import core.keychain as keychain
    return keychain


def provider_def(provider):
    return PROVIDERS.get(str(provider or "").strip().lower())


def slot_name(provider):
    return f"oauth_{str(provider or '').strip().lower()}"


def client(provider):
    """`(client_id, client_secret)` from the env names the registry declares."""
    p = provider_def(provider) or {}
    return ((os.environ.get(p.get("env_id", "")) or "").strip(),
            (os.environ.get(p.get("env_secret", "")) or "").strip())


def configured(provider="google"):
    cid, secret = client(provider)
    return bool(cid and secret)


def safe_next(raw):
    """A RETURN PATH the callback may redirect to: same-origin RELATIVE only. Anything absolute,
    protocol-relative or unparseable degrades to the shell root β€” a redirect target that
    arrived in a query string is exactly the thing an open-redirect rides in on."""
    nxt = str(raw or "").strip()
    if not nxt or not nxt.startswith(("/", "#")) or nxt.startswith("//") or "\\" in nxt \
            or ":" in nxt.split("?", 1)[0].split("#", 1)[0]:
        return "/#/"
    return nxt[:300]


# ---------------------------------------------------------------------------------------------
# PKCE + state
# ---------------------------------------------------------------------------------------------

_STATE_LOCK = threading.Lock()
_STATES = {}          # state -> {"provider", "verifier", "uname", "redirect", "next", "ts"}


def _prune_states(now=None):
    now = now if now is not None else time.time()
    with _STATE_LOCK:
        for k in [k for k, v in _STATES.items() if now - v.get("ts", 0) > STATE_TTL_SECONDS]:
            _STATES.pop(k, None)


def _challenge(verifier):
    """S256: BASE64URL(SHA256(verifier)), no padding β€” RFC 7636's one worthwhile method."""
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")


def start(provider, uname, redirect_uri, next_path=""):
    """Mint state+verifier for THIS user and return the provider's consent URL.

    Returns `(url, error)` β€” an unknown or unconfigured provider is a sentence, never an
    exception (rule 2)."""
    p = provider_def(provider)
    if p is None:
        return "", f"{provider!r} is not a connectable provider"
    if not configured(provider):
        return "", (f"{p['label']} OAuth is not configured on this deployment β€” the owner adds "
                    f"{p['env_id']} / {p['env_secret']} first")
    _prune_states()
    cid, _secret = client(provider)
    state = secrets.token_urlsafe(24)
    verifier = secrets.token_urlsafe(64)          # 86 chars β€” inside RFC 7636's 43..128
    with _STATE_LOCK:
        _STATES[state] = {"provider": str(provider), "verifier": verifier,
                          "uname": str(uname or ""), "redirect": str(redirect_uri or ""),
                          "next": safe_next(next_path), "ts": time.time()}
    q = {
        "client_id": cid,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": p["scopes"],
        "state": state,
        "code_challenge": _challenge(verifier),
        "code_challenge_method": "S256",
    }
    q.update(p.get("authorize_extra") or {})
    return f"{p['authorize_url']}?{urlencode(q)}", ""


def _post_token(url, body):
    """One POST to a REGISTERED token endpoint. `(payload, error_sentence)` β€” never raises,
    never echoes a secret; a non-200 quotes the status and the provider's `error` field only."""
    try:
        r = requests.post(url, data=body, timeout=HTTP_TIMEOUT,
                          headers={"Accept": "application/json"})
    except Exception as e:                                        # noqa: BLE001
        return None, f"the token endpoint did not answer ({type(e).__name__})"
    try:
        payload = r.json() if r.content else {}
    except Exception:                                             # noqa: BLE001
        payload = {}
    if r.status_code != 200:
        why = str((payload or {}).get("error") or r.status_code)
        return None, f"the token exchange was refused ({why})"
    return payload or {}, ""


def _userinfo_email(provider, access_token):
    p = provider_def(provider) or {}
    url = p.get("userinfo_url")
    if not url:
        return ""
    try:
        r = requests.get(url, timeout=HTTP_TIMEOUT,
                         headers={"Authorization": f"Bearer {access_token}"})
        if r.status_code == 200:
            return str((r.json() or {}).get("email") or "")
    except Exception:                                             # noqa: BLE001
        pass
    return ""


def callback(rt, uname, state, code):
    """The exchange half: verify the state belongs to THIS user, trade the code for tokens,
    store `{access, refresh, expiry, email}` in the user's per-provider slot. Returns
    `(email, next_path, error)` β€” `next_path` is where the browser goes home to (A3)."""
    _prune_states()
    with _STATE_LOCK:
        entry = _STATES.pop(str(state or ""), None)      # single-use, whatever happens next
    home = safe_next((entry or {}).get("next"))
    if not entry:
        return "", home, "that connect attempt is unknown or expired β€” start again"
    if entry.get("uname") != str(uname or ""):
        # A state minted for one session consumed by another is exactly the CSRF the state
        # exists to refuse. Fail closed; the honest user just clicks Connect again.
        return "", home, "that connect attempt belongs to a different session β€” start again"
    provider = entry.get("provider") or "google"
    p = provider_def(provider)
    if p is None or not configured(provider):
        return "", home, f"{provider!r} is not connectable on this deployment"
    cid, secret = client(provider)
    payload, err = _post_token(p["token_url"], {
        "client_id": cid, "client_secret": secret,
        "grant_type": "authorization_code", "code": str(code or ""),
        "redirect_uri": entry.get("redirect") or "",
        "code_verifier": entry.get("verifier") or "",
    })
    if err:
        return "", home, err
    access = str(payload.get("access_token") or "")
    refresh = str(payload.get("refresh_token") or "")
    if not access:
        return "", home, "the provider answered without an access token β€” nothing was stored"
    if not refresh:
        # Google answers access-only when consent was silently reused β€” an access-only slot
        # dies within the hour, silently, so it is refused with the way out named.
        return "", home, ("no refresh token came back β€” disconnect the app on the provider's "
                          "permissions page and connect again")
    expiry = time.time() + float(payload.get("expires_in") or 3600) - 60
    email = _userinfo_email(provider, access)
    _kc().put_user_secret(rt, uname, slot_name(provider), {
        "access": access, "refresh": refresh,
        "expiry": f"{expiry:.0f}", "email": email,
    })
    return email, home, ""


def _refresh(rt, uname, provider, slot):
    """Trade the refresh token for a fresh access token and re-store. `(slot|None, error)`.

    A refused refresh marks the slot `reconnect` LOUDLY rather than deleting it β€” the status
    endpoint then says "reconnect" instead of "not connected", which are different instructions
    to a person (the Testing-mode 7-day expiry makes this path ROUTINE, not rare)."""
    p = provider_def(provider) or {}
    cid, secret = client(provider)
    payload, err = _post_token(p.get("token_url", ""), {
        "client_id": cid, "client_secret": secret,
        "grant_type": "refresh_token", "refresh_token": str(slot.get("refresh") or ""),
    })
    if err:
        marked = dict(slot)
        marked["reconnect"] = "1"
        try:
            _kc().put_user_secret(rt, uname, slot_name(provider), marked)
        except Exception:                                         # noqa: BLE001
            pass
        return None, err
    fresh = dict(slot)
    fresh["access"] = str(payload.get("access_token") or slot.get("access") or "")
    fresh["expiry"] = f"{time.time() + float(payload.get('expires_in') or 3600) - 60:.0f}"
    fresh.pop("reconnect", None)
    _kc().put_user_secret(rt, uname, slot_name(provider), fresh)
    return fresh, ""


def creds(rt, uname, provider="google"):
    """THE seam a poller reads through: a live access token for this user, refreshed
    transparently on expiry. Returns `(access_token, error_sentence)` β€” `("", why)` covers
    not-connected, locked keychain and reconnect-needed alike, each with its own sentence."""
    kc = _kc()
    p = provider_def(provider) or {"label": str(provider)}
    try:
        slot = kc.read_user_secret(rt, uname, slot_name(provider))
    except kc.KeychainLocked as e:
        return "", f"the keychain is locked ({e}) β€” no token can be read"
    if not slot:
        return "", (f"no {p['label']} connection for this user β€” connect it in Settings")
    if slot.get("reconnect"):
        return "", f"the {p['label']} connection expired β€” reconnect it in Settings"
    try:
        expiry = float(slot.get("expiry") or 0)
    except (TypeError, ValueError):
        expiry = 0.0
    if time.time() >= expiry:
        slot, err = _refresh(rt, uname, provider, slot)
        if err:
            return "", f"the {p['label']} connection could not refresh β€” {err}"
    return str(slot.get("access") or ""), ""


def google_creds(rt, uname):
    """The email trigger's named seam (contract C5's original spelling) β€” `creds` on the one
    provider this wave ships."""
    return creds(rt, uname, "google")


def status(rt, uname):
    """C5's status shape, registry-driven: `{<provider>: {connected, email, reconnect,
    configured}}` for the session user β€” one entry per registry row, so today it reads exactly
    `{google: {...}}`. Never raises β€” a locked keychain reads as not-connected WITH the note."""
    kc = _kc()
    out = {}
    for slug in PROVIDERS:
        row = {"connected": False, "email": "", "reconnect": False,
               "configured": configured(slug)}
        try:
            slot = kc.read_user_secret(rt, uname, slot_name(slug))
        except kc.KeychainLocked as e:
            row["note"] = str(e)
            out[slug] = row
            continue
        if slot:
            row["connected"] = True
            row["email"] = str(slot.get("email") or "")
            row["reconnect"] = bool(slot.get("reconnect"))
        out[slug] = row
    return out


def disconnect(rt, uname, provider="google"):
    _kc().drop_user_secret(rt, uname, slot_name(provider))
    return True


# ---------------------------------------------------------------------------------------------
# GMAIL READS β€” the one data surface this wave (the email trigger's substrate)
# ---------------------------------------------------------------------------------------------

def gmail_list(access_token, query, max_results=25):
    """Message ids matching `query`, newest first. `(ids, error)` β€” bounded, timeboxed, and a
    non-200 is a sentence (the poll runs on the tick thread; it may be slow ONCE, never hung)."""
    q = urlencode({"q": str(query or "").strip() or "in:inbox", "maxResults": int(max_results)})
    try:
        r = requests.get(f"{GMAIL_BASE}/messages?{q}", timeout=HTTP_TIMEOUT,
                         headers={"Authorization": f"Bearer {access_token}"})
    except Exception as e:                                        # noqa: BLE001
        return [], f"Gmail did not answer ({type(e).__name__})"
    if r.status_code != 200:
        return [], f"Gmail answered {r.status_code}"
    try:
        msgs = (r.json() or {}).get("messages") or []
    except Exception:                                             # noqa: BLE001
        return [], "Gmail answered with something that is not JSON"
    return [str(m.get("id")) for m in msgs if isinstance(m, dict) and m.get("id")], ""


def gmail_message(access_token, message_id):
    """One message's headers + snippet β†’ our row shape. `(row|None, error)`."""
    try:
        r = requests.get(
            f"{GMAIL_BASE}/messages/{message_id}"
            f"?format=metadata&metadataHeaders=From&metadataHeaders=Subject"
            f"&metadataHeaders=Date",
            timeout=HTTP_TIMEOUT,
            headers={"Authorization": f"Bearer {access_token}"})
    except Exception as e:                                        # noqa: BLE001
        return None, f"Gmail did not answer ({type(e).__name__})"
    if r.status_code != 200:
        return None, f"Gmail answered {r.status_code}"
    try:
        payload = r.json() or {}
    except Exception:                                             # noqa: BLE001
        return None, "Gmail answered with something that is not JSON"
    headers = {str(h.get("name") or "").lower(): str(h.get("value") or "")
               for h in ((payload.get("payload") or {}).get("headers") or [])
               if isinstance(h, dict)}
    stamp = _dt.datetime.now().astimezone().isoformat(timespec="seconds")
    return {
        "email_id": str(payload.get("id") or message_id),
        "email_from": headers.get("from", "")[:200],
        "email_subject": headers.get("subject", "")[:300],
        "email_date": headers.get("date", "")[:80],
        "email_snippet": str(payload.get("snippet") or "")[:500],
        "email_seen_at": stamp,
    }, ""


#: `re` is imported for the slot-name law in core.keychain (see USER_SECRET_PROVIDERS there);
#: keeping the reference here so a linter cannot 'clean up' what the registry contract uses.
_SLOT_SHAPE = re.compile(r"^oauth_[a-z0-9_]{1,24}$")


# ═════════════════════════════════════════════════════════════════════════════════════════════
# ⭐ W31-T41 β€” THE NANGO PROBE. Inert on import; reached only by `python oauth_connect.py --nango`.
# ═════════════════════════════════════════════════════════════════════════════════════════════
#
# R5 makes Nango a production dependency ("Meta connects by clicking Connect Facebook; Nango owns
# that flow"), and T41 is its GO/NO-GO: does the key authenticate, does the Facebook integration
# carry Marketing API scopes (`ads_read`), and what does the free tier allow against our connection
# count. β›” It lives HERE because `::creds` is the seam every poller reads and therefore the seam
# Nango slots into β€” the probe belongs beside the thing it would replace, not in a scratch file
# that dies with the session.
#
# ⚠ NOTHING BELOW IS WIRED INTO THE FLOW. `PROVIDERS`, `start`, `callback` and `creds` are
# untouched by this block: a measurement that changes the live auth path before its own verdict is
# in is the shape R5 was careful to avoid.
NANGO_API = os.environ.get("NANGO_API_BASE") or "https://api.nango.dev"


def nango_key():
    """`NANGO_SECRET_KEY` from the environment or gitignored `platform/.env`; "" when absent."""
    from pathlib import Path as _Path
    tok = os.environ.get("NANGO_SECRET_KEY") or ""
    if tok:
        return tok.strip()
    env = _Path(__file__).resolve().parents[2] / "platform" / ".env"
    if env.exists():
        for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
            if line.strip().startswith("NANGO_SECRET_KEY"):
                _, _, v = line.partition("=")
                return v.strip().strip('"').strip("'")
    return ""


def nango_probe():
    """`{authenticated, integrations, connections, errors}` β€” read-only GETs against Nango Cloud.

    β›” NEVER PRINTS THE KEY. It rides an Authorization header and the report carries integration
    ids and scope strings only.
    ⚠ The scope question is the GO/NO-GO: an integration row's own config states which scopes it
    requests, and `ads_read` is what R2's Insights reads need. If it is absent, the fallback is the
    keychain token path β€” which `connectors_meta.py` has already built β€” and that has to be said
    BEFORE any OAuth code is written, per the ticket.
    """
    import base64
    import json as _json
    import urllib.error
    import urllib.request
    key = nango_key()
    out = {"base": NANGO_API, "authenticated": False, "integrations": [], "connections": [],
           "errors": []}
    if not key:
        out["errors"].append(
            "NANGO_SECRET_KEY is not set in the environment or in platform/.env. Nothing was "
            "measured. The PRD records it as 'recorded in platform/.env' and it is not there.")
        return out
    # β›” BEARER, NOT BASIC β€” measured 2026-08-12 by A, against the owner's real key, once it landed.
    # Basic (`base64(key + ":")`, the OLD Nango style) answers **401 `invalid_secret_key_format`:
    # "The provided secret key is not a UUID v4."** β€” which reads exactly like a bad credential and
    # is not: Nango takes the whole base64 blob as the key and correctly observes it is not a UUID.
    # The SAME key over `Bearer` answers **200 with 1 integration**. ⭐ The lesson is the error
    # message: it described the VALUE when the fault was in the ENVELOPE, so the obvious next step
    # ("ask the owner for a valid key") would have been wrong and would have read as their mistake.
    auth = f"Bearer {key}"

    def _get(path):
        req = urllib.request.Request(f"{NANGO_API}{path}",
                                     headers={"Authorization": auth,
                                              "User-Agent": "aios-nango-probe/1"})
        with urllib.request.urlopen(req, timeout=45) as r:
            return _json.loads(r.read().decode("utf-8", "replace"))

    for label, path, sink in (("integrations", "/integrations", "integrations"),
                              ("connections", "/connection", "connections")):
        try:
            body = _get(path)
            rows = body if isinstance(body, list) else (body.get("configs")
                                                        or body.get("connections")
                                                        or body.get("data") or [])
            out[sink] = rows
            out["authenticated"] = True
        except urllib.error.HTTPError as e:
            out["errors"].append(f"{label}: HTTP {e.code} β€” "
                                 f"{e.read().decode('utf-8', 'replace')[:200]}")
        except Exception as e:                                    # noqa: BLE001
            out["errors"].append(f"{label}: {type(e).__name__}: {e}")
    return out


if __name__ == "__main__":                                        # pragma: no cover
    import json as _j
    import sys as _sys
    if "--nango" in _sys.argv:
        rep = nango_probe()
        print(f"Nango key present: {'yes' if nango_key() else 'NO'}  (never printed)")
        print(f"authenticated: {rep['authenticated']} Β· integrations: "
              f"{len(rep['integrations'])} Β· connections: {len(rep['connections'])}")
        for e in rep["errors"]:
            print(f"  ERROR {e}")
        for i in rep["integrations"]:
            if isinstance(i, dict):
                print("  integration:", _j.dumps({k: v for k, v in i.items()
                                                  if k in ("unique_key", "provider", "scopes",
                                                           "oauth_scopes")}))
        _sys.exit(0 if rep["authenticated"] else 2)
    print("usage: python oauth_connect.py --nango")
    _sys.exit(2)