Merge pull request #295 from superdesigndev/codex/instagram-oauth-architecture

feat(instagram): add direct Instagram Login with method-aware Page tools
This commit is contained in:
Taus
2026-09-02 11:05:04 +06:00
committed by GitHub
50 changed files with 3061 additions and 652 deletions
+26 -17
View File
@@ -30,7 +30,7 @@ Regenerate via `scripts/build-map.py`.
| `render.yaml` | ops/deploy.md |
| `scripts/build_plugin.py` | interface/skill.md |
| `scripts/catalog_drift.py` | architecture/catalog.md |
| `scripts/catalog_ingest.py` | architecture/catalog.md |
| `scripts/catalog_ingest.py` | architecture/catalog.md, architecture/instagram-oauth.md |
| `scripts/catalog_validate.py` | architecture/catalog.md |
| `scripts/dev-local.sh` | ops/deploy.md |
| `scripts/dump_surface.py` | architecture/composition.md |
@@ -51,21 +51,23 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/alembic/versions/0007_overflow_spend.py` | architecture/data-model.md, ops/capacity.md |
| `src/treg/alembic/versions/0008_org_platform_overflow_disabled.py` | architecture/data-model.md, ops/capacity.md |
| `src/treg/alembic/versions/0009_callrecord_hit.py` | architecture/data-model.md |
| `src/treg/alembic/versions/0010_oauth_authorization_method.py` | architecture/instagram-oauth.md |
| `src/treg/analytics.py` | architecture/data-model.md |
| `src/treg/api.py` | architecture/archive.md, architecture/money.md, architecture/multi-tenancy.md, architecture/proxy-model.md, architecture/super-admin.md, interface/api.md, interface/dashboard.md, interface/landing-sandbox.md, interface/seo.md |
| `src/treg/application/__init__.py` | architecture/import-boundaries.md |
| `src/treg/application/auth.py` | architecture/data-model.md, architecture/mcp-oauth.md, architecture/multi-tenancy.md, interface/api.md, interface/onboarding.md |
| `src/treg/application/billing.py` | architecture/money.md |
| `src/treg/application/call/__init__.py` | architecture/import-boundaries.md |
| `src/treg/application/call/access.py` | architecture/import-boundaries.md, architecture/instagram-oauth.md, interface/api.md |
| `src/treg/application/call/authorize.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/evidence.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/idempotency.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/intake.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/overflow.py` | architecture/import-boundaries.md, ops/capacity.md |
| `src/treg/application/call/reserve.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/resolve.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/resolve.py` | architecture/import-boundaries.md, architecture/instagram-oauth.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/route.py` | architecture/catalog.md, architecture/import-boundaries.md |
| `src/treg/application/call/service.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/service.py` | architecture/import-boundaries.md, architecture/instagram-oauth.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/settle.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/types.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/connect.py` | architecture/auth-secrets.md, architecture/composition.md, guides/expanding-a-category.md, interface/api.md |
@@ -123,11 +125,11 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/catalog/google-search-console.yaml` | architecture/catalog.md |
| `src/treg/catalog/google-tag-manager.extended.yaml` | architecture/catalog.md |
| `src/treg/catalog/google-tag-manager.yaml` | architecture/catalog.md |
| `src/treg/catalog/instagram.extended.yaml` | architecture/catalog.md |
| `src/treg/catalog/instagram.yaml` | architecture/catalog.md |
| `src/treg/catalog/instagram.extended.yaml` | architecture/catalog.md, architecture/instagram-oauth.md |
| `src/treg/catalog/instagram.yaml` | architecture/catalog.md, architecture/instagram-oauth.md |
| `src/treg/catalog/justoneapi.extended.yaml` | architecture/catalog.md |
| `src/treg/catalog/tikhub.extended.yaml` | architecture/catalog.md |
| `src/treg/cli.py` | interface/cli.md, interface/onboarding.md, interface/shell.md |
| `src/treg/cli.py` | architecture/instagram-oauth.md, interface/cli.md, interface/onboarding.md, interface/shell.md |
| `src/treg/client_identity.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/config.py` | architecture/super-admin.md, guides/expanding-a-category.md, ops/deploy.md |
| `src/treg/convert.py` | interface/cli.md |
@@ -152,9 +154,11 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/domain/catalog/routing/plan.py` | architecture/catalog.md |
| `src/treg/domain/catalog/routing/synthetic.py` | architecture/catalog.md |
| `src/treg/domain/catalog/stats.py` | architecture/catalog.md |
| `src/treg/domain/catalog/store.py` | architecture/catalog.md, interface/api.md, interface/catalog-review-proposal.md |
| `src/treg/domain/connections/__init__.py` | architecture/auth-secrets.md |
| `src/treg/domain/connections/refresh.py` | architecture/auth-secrets.md |
| `src/treg/domain/catalog/store.py` | architecture/catalog.md, architecture/instagram-oauth.md, interface/api.md, interface/catalog-review-proposal.md |
| `src/treg/domain/connections/__init__.py` | architecture/auth-secrets.md, architecture/import-boundaries.md |
| `src/treg/domain/connections/authorization.py` | architecture/auth-secrets.md, architecture/import-boundaries.md, architecture/instagram-oauth.md, guides/expanding-a-category.md |
| `src/treg/domain/connections/oauth_flow.py` | architecture/auth-secrets.md, architecture/import-boundaries.md, architecture/instagram-oauth.md, guides/expanding-a-category.md |
| `src/treg/domain/connections/refresh.py` | architecture/auth-secrets.md, architecture/import-boundaries.md |
| `src/treg/domain/governance/__init__.py` | architecture/import-boundaries.md |
| `src/treg/domain/governance/access.py` | architecture/import-boundaries.md, architecture/multi-tenancy.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/domain/governance/budgets.py` | architecture/import-boundaries.md, architecture/money.md, architecture/multi-tenancy.md, interface/api.md |
@@ -179,6 +183,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/infra/__init__.py` | architecture/money.md |
| `src/treg/infra/catalog_observations.py` | architecture/catalog.md |
| `src/treg/infra/db.py` | architecture/data-model.md, architecture/multi-tenancy.md, ops/deploy.md |
| `src/treg/infra/oauth_exchange.py` | architecture/auth-secrets.md, architecture/instagram-oauth.md, guides/expanding-a-category.md |
| `src/treg/infra/oauth_refresh.py` | architecture/auth-secrets.md |
| `src/treg/infra/stripe.py` | architecture/money.md |
| `src/treg/infra/upstream/__init__.py` | architecture/import-boundaries.md |
@@ -193,7 +198,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/localproxy.py` | architecture/local-proxy.md |
| `src/treg/localrun.py` | architecture/local-run.md |
| `src/treg/maintenance.py` | architecture/data-model.md, ops/deploy.md |
| `src/treg/mcp.py` | architecture/mcp-oauth.md |
| `src/treg/mcp.py` | architecture/instagram-oauth.md, architecture/mcp-oauth.md |
| `src/treg/mcp_install.py` | interface/skill.md |
| `src/treg/models.py` | architecture/data-model.md, architecture/money.md, architecture/multi-tenancy.md |
| `src/treg/oauth.py` | architecture/auth-secrets.md |
@@ -206,7 +211,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/routers/auth.py` | architecture/composition.md, architecture/mcp-oauth.md, architecture/multi-tenancy.md, interface/api.md, interface/onboarding.md |
| `src/treg/routers/auth_helpers.py` | interface/api.md |
| `src/treg/routers/billing.py` | architecture/composition.md, architecture/money.md, interface/api.md |
| `src/treg/routers/call.py` | architecture/composition.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/routers/call.py` | architecture/composition.md, architecture/instagram-oauth.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/routers/catalog.py` | architecture/catalog.md, interface/api.md |
| `src/treg/routers/connections.py` | architecture/auth-secrets.md, architecture/composition.md, guides/expanding-a-category.md, interface/api.md |
| `src/treg/routers/onboard.py` | architecture/composition.md, interface/api.md, interface/landing-sandbox.md, interface/onboarding.md |
@@ -225,10 +230,12 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/web/catalog.css` | interface/seo.md |
| `src/treg/web/claude-connector.html` | architecture/mcp-oauth.md |
| `src/treg/web/connect-demo.html` | architecture/mcp-oauth.md |
| `src/treg/web/index.html` | interface/dashboard.md, interface/landing-sandbox.md, interface/onboarding.md, interface/seo.md |
| `src/treg/web/grokbot.html` | interface/seo.md |
| `src/treg/web/index.html` | architecture/instagram-oauth.md, interface/dashboard.md, interface/landing-sandbox.md, interface/onboarding.md, interface/seo.md |
| `src/treg/web/install.sh` | interface/landing-sandbox.md |
| `src/treg/web/landing.html` | interface/seo.md |
| `src/treg/web/llms.txt` | interface/seo.md |
| `src/treg/web/people-search.html` | interface/seo.md |
| `src/treg/web/robots.txt` | interface/seo.md |
| `src/treg/web/selfhost.sh` | ops/deploy.md |
| `src/treg/web/sitetrack.js` | architecture/data-model.md, interface/api.md, interface/dashboard.md |
@@ -256,6 +263,7 @@ Regenerate via `scripts/build-map.py`.
| `tests/test_capacity_smoothing.py` | ops/capacity.md |
| `tests/test_error_capture.py` | architecture/proxy-model.md |
| `tests/test_import_lightness.py` | architecture/import-boundaries.md |
| `tests/test_instagram_oauth_architecture.py` | architecture/instagram-oauth.md |
| `tests/test_marketplace_call.py` | architecture/mcp-oauth.md, architecture/proxy-model.md |
| `tests/test_mcp.py` | architecture/mcp-oauth.md |
| `tests/test_mcp_directory.py` | architecture/mcp-oauth.md |
@@ -274,11 +282,12 @@ Regenerate via `scripts/build-map.py`.
|---|---|
| `architecture/ads-conversions.md` | `adsconv.py`, `signup.py`, `adtrack.js` |
| `architecture/archive.md` | `archive.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `api.py`, `bootstrap.py`, `admin.py` |
| `architecture/auth-secrets.md` | `injectors.py`, `ssrf.py`, `crypto.py`, `oauth.py`, `__init__.py`, `refresh.py`, `oauth_refresh.py`, `oauth_providers.py`, `health.py`, `connect.py`, `connections.py`, `resources.py`, `__init__.py`, `bindings.py`, `bundles.py`, `test_oauth_refresh.py` |
| `architecture/auth-secrets.md` | `injectors.py`, `ssrf.py`, `crypto.py`, `oauth.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `oauth_exchange.py`, `oauth_refresh.py`, `oauth_providers.py`, `health.py`, `connect.py`, `connections.py`, `resources.py`, `__init__.py`, `bindings.py`, `bundles.py`, `test_oauth_refresh.py` |
| `architecture/catalog.md` | `contracts.yaml`, `adapters.yaml`, `findymail.search.business-profile.json`, `__init__.py`, `contracts.py`, `paths.py`, `plan.py`, `synthetic.py`, `route.py`, `test_routing.py`, `catalog-drift.yml`, `catalog_drift.py`, `catalog_ingest.py`, `catalog_validate.py`, `aliases.yaml`, `fx.yaml`, `aviato.yaml`, `crustdata.yaml`, `aviato.companies.acquisitions.json`, `aviato.companies.employees.json`, `aviato.companies.enrich.bulk.json`, `aviato.companies.enrich.json`, `aviato.companies.founders.json`, `aviato.companies.funding_rounds.json`, `aviato.companies.investments.json`, `aviato.companies.outbound_investments.json`, `aviato.companies.search.json`, `aviato.linkedin.company.posts.json`, `aviato.linkedin.post.comments.json`, `aviato.linkedin.post.reactions.json`, `aviato.linkedin.post.reposts.json`, `aviato.linkedin.user.posts.json`, `aviato.people.contact.get.json`, `aviato.people.email.find.json`, `aviato.people.enrich.bulk.json`, `aviato.people.enrich.json`, `aviato.people.phone.find.json`, `aviato.people.search.json`, `aviato.people.search.simple.json`, `crustdata.companies.autocomplete.json`, `crustdata.companies.enrich.json`, `crustdata.companies.identify.json`, `crustdata.companies.jobs.search.json`, `crustdata.companies.search.json`, `crustdata.people.autocomplete.json`, `crustdata.people.enrich.json`, `crustdata.people.search.json`, `google-search-console.yaml`, `google-search-console.extended.yaml`, `google-tag-manager.yaml`, `google-tag-manager.extended.yaml`, `instagram.yaml`, `instagram.extended.yaml`, `justoneapi.extended.yaml`, `tikhub.extended.yaml`, `__init__.py`, `store.py`, `stats.py`, `catalog_observations.py`, `catalog.py` |
| `architecture/composition.md` | `bootstrap.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `connect.py`, `mcp_oauth.py`, `session.py`, `admin.py`, `auth.py`, `billing.py`, `call.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `web.py`, `dump_surface.py`, `test_app_roles.py` |
| `architecture/data-model.md` | `alembic.ini`, `env.py`, `0001_baseline_current_schema.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `0005_capacity_policy_snapshot.py`, `0006_overflow_route.py`, `0007_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `0009_callrecord_hit.py`, `maintenance.py`, `sitetrack.js`, `models.py`, `timeutil.py`, `db.py`, `referrals.py`, `audit.py`, `analytics.py`, `ratestore.py`, `auth.py`, `test_postgres_reset.py`, `test_alembic_expand_safety.py` |
| `architecture/import-boundaries.md` | `pyproject.toml`, `ci.yml`, `__init__.py`, `__init__.py`, `authorize.py`, `idempotency.py`, `overflow.py`, `route.py`, `__init__.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `client_identity.py`, `__init__.py`, `__init__.py`, `access.py`, `budgets.py`, `publicdemo.py`, `teams.py`, `usage.py`, `__init__.py`, `__init__.py`, `__init__.py`, `__init__.py`, `injectors.py`, `relay.py`, `__init__.py`, `limiter.py`, `test_call_architecture.py`, `test_import_lightness.py` |
| `architecture/import-boundaries.md` | `pyproject.toml`, `ci.yml`, `__init__.py`, `__init__.py`, `access.py`, `authorize.py`, `idempotency.py`, `overflow.py`, `route.py`, `__init__.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `client_identity.py`, `__init__.py`, `__init__.py`, `access.py`, `budgets.py`, `publicdemo.py`, `teams.py`, `usage.py`, `__init__.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `__init__.py`, `__init__.py`, `__init__.py`, `injectors.py`, `relay.py`, `__init__.py`, `limiter.py`, `test_call_architecture.py`, `test_import_lightness.py` |
| `architecture/instagram-oauth.md` | `catalog_ingest.py`, `access.py`, `resolve.py`, `service.py`, `instagram.yaml`, `instagram.extended.yaml`, `cli.py`, `store.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `mcp.py`, `call.py`, `index.html`, `0010_oauth_authorization_method.py`, `test_instagram_oauth_architecture.py` |
| `architecture/local-proxy.md` | `localproxy.py`, `server.js` |
| `architecture/local-run.md` | `localrun.py`, `egress.py`, `fsjail.py` |
| `architecture/mcp-oauth.md` | `auth.py`, `mcp.py`, `health.py`, `mcp_oauth.py`, `session.py`, `auth.py`, `claude-connector.html`, `connect-demo.html`, `CLAUDE-CONNECTOR-SUBMISSION.md`, `test_mcp.py`, `test_mcp_directory.py`, `test_marketplace_call.py` |
@@ -287,15 +296,15 @@ Regenerate via `scripts/build-map.py`.
| `architecture/proxy-model.md` | `relay.py`, `ssrf.py`, `api.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `client_identity.py`, `sandbox_identity.py`, `access.py`, `publicdemo.py`, `usage.py`, `call.py`, `test_call_application_contract.py`, `test_call_cancellation.py`, `test_error_capture.py`, `test_marketplace_call.py`, `test_oauth_billed.py`, `test_passthrough.py`, `test_tag_billing.py`, `test_tag_billing_adversarial.py`, `test_call_architecture.py` |
| `architecture/super-admin.md` | `api.py`, `admin.py`, `access.py`, `config.py` |
| `foundation/charter.md` | `2026-06-30-jason-tools-registry.md`, `README.md` |
| `guides/expanding-a-category.md` | `oauth_providers.py`, `connect.py`, `connections.py`, `config.py` |
| `interface/api.md` | `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` |
| `guides/expanding-a-category.md` | `oauth_providers.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `connect.py`, `connections.py`, `config.py` |
| `interface/api.md` | `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` |
| `interface/catalog-review-proposal.md` | `store.py`, `capabilities.yaml` |
| `interface/cli.md` | `cli.py`, `convert.py`, `agents.py` |
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py` |
| `interface/env-import.md` | `providers.py`, `skills.py` |
| `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `index.html`, `install.sh` |
| `interface/onboarding.md` | `auth.py`, `__init__.py`, `demo.py`, `cli.py`, `auth.py`, `onboard.py`, `index.html` |
| `interface/seo.md` | `api.py`, `web.py`, `agent_pages.py`, `robots.txt`, `catalog.css`, `usecase.css`, `index.html`, `landing.html`, `llms.txt`, `indexnow_submit.py`, `support.html`, `og-card.html` |
| `interface/seo.md` | `api.py`, `web.py`, `agent_pages.py`, `robots.txt`, `catalog.css`, `usecase.css`, `index.html`, `landing.html`, `people-search.html`, `grokbot.html`, `llms.txt`, `indexnow_submit.py`, `support.html`, `og-card.html` |
| `interface/shell.md` | `shell.py`, `cli.py` |
| `interface/skill.md` | `skill.md`, `web.py`, `mcp_install.py`, `build_plugin.py`, `plugin.json`, `marketplace.json`, `plugin.json`, `plugin.json`, `package.json`, `cordis.patch.yml`, `index.js`, `plugin.json`, `minimax_plugin.py` |
| `ops/capacity.md` | `__init__.py`, `collectors.py`, `policy.py`, `sweep.py`, `view.py`, `routes.py`, `signatures.py`, `verify.py`, `marks.py`, `test_capacity_protect.py`, `limiter.py`, `overflow_spend.py`, `routes_view.py`, `overflow.py`, `0007_overflow_spend.py`, `test_capacity_overflow.py`, `test_capacity_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `test_capacity_smoothing.py`, `overflow_seed.json`, `__init__.py`, `orthogonal.py`, `monid.py`, `catalogs.py`, `0006_overflow_route.py`, `test_capacity_overflow_routes.py`, `worker.py`, `provider_balances.py`, `0005_capacity_policy_snapshot.py`, `test_capacity_know.py`, `test_capacity_collectors.py` |
+2
View File
@@ -294,6 +294,8 @@ Environment variables (prefix `TREG_`, read from `.env`):
| `TREG_SESSION_SECRET` | *(empty)* | signs the dashboard session cookie; falls back to `TREG_SECRET_KEY`. Set a real value in prod |
| `TREG_GITHUB_CLIENT_ID` / `_SECRET` | *(empty)* | GitHub OAuth sign-in (callback `<public_url>/auth/github/callback`); empty hides the button |
| `TREG_GOOGLE_CLIENT_ID` / `_SECRET` | *(empty)* | Google OAuth sign-in (redirect `<public_url>/auth/google/callback`); empty hides the button |
| `TREG_INSTAGRAM_CLIENT_ID` / `_SECRET` | *(empty)* | Instagram App ID and secret for direct Instagram Login (redirect `<public_url>/oauth/callback`) |
| `TREG_META_CLIENT_ID` / `_SECRET` | *(empty)* | Meta app credentials for Facebook Pages, Meta Ads, and optional Instagram `page-tools` |
| `TREG_RESEND_API_KEY` / `TREG_EMAIL_FROM` | *(empty)* | transactional email via Resend (OTP codes + invites); From must be a Resend-verified sender |
| `TREG_ADMIN_TOKEN` | *(empty)* | cross-tenant **super-admin** bearer; authorizes every `/admin/*` endpoint. Empty disables the env path (only `is_superadmin` users reach `/admin`). Keep it long + secret. |
| `TREG_EMAIL_DEV_MODE` | `false` | when true, `/auth/email/start` returns the OTP in its response (no mail sender needed) — **dev/local only**, never in prod. |
+2 -1
View File
@@ -23,6 +23,7 @@ covers (frontmatter `sources:`). Regenerate this index with
| [Application composition and deployment roles](architecture/composition.md) | shipped | bootstrap.py, bootstrap_handlers.py, bootstrap_http.py, connect.py, … |
| [Data model — the registry tables, async DB, audit writer](architecture/data-model.md) | shipped | alembic.ini, env.py, 0001_baseline_current_schema.py, 0002_archive_tables.py, … |
| [Enforced import boundaries](architecture/import-boundaries.md) | shipped | pyproject.toml, ci.yml, __init__.py, __init__.py, … |
| [Instagram OAuth — direct Login and optional Facebook Page tools](architecture/instagram-oauth.md) | built; Meta configuration and live verification pending | catalog_ingest.py, access.py, resolve.py, service.py, … |
| [Local proxy — catch a program's own outgoing calls (`treg <command>`)](architecture/local-proxy.md) | shipped | localproxy.py, server.js |
| [Local CLI runs — run a vendor CLI as a dedicated user with a server-held credential (`treg run`)](architecture/local-run.md) | shipped | localrun.py, egress.py, fsjail.py |
| [MCP — the front door for assistants, and treg as an OAuth authorization server](architecture/mcp-oauth.md) | shipped | auth.py, mcp.py, health.py, mcp_oauth.py, … |
@@ -63,5 +64,5 @@ covers (frontmatter `sources:`). Regenerate this index with
| Fragment | Status | Covers |
|---|---|---|
| [Expanding a marketplace category — the add-a-provider playbook](guides/expanding-a-category.md) | guide | oauth_providers.py, connect.py, connections.py, config.py |
| [Expanding a catalog category — the add-a-provider playbook](guides/expanding-a-category.md) | guide | oauth_providers.py, authorization.py, oauth_flow.py, oauth_exchange.py, … |
+36 -2
View File
@@ -7,7 +7,10 @@ sources:
- src/treg/crypto.py
- src/treg/oauth.py
- src/treg/domain/connections/__init__.py
- src/treg/domain/connections/authorization.py
- src/treg/domain/connections/oauth_flow.py
- src/treg/domain/connections/refresh.py
- src/treg/infra/oauth_exchange.py
- src/treg/infra/oauth_refresh.py
- src/treg/oauth_providers.py
- src/treg/health.py
@@ -26,6 +29,30 @@ related:
# Auth & secrets
## Instagram grant methods (2026-09-01)
Instagram is one provider with two explicit protocol profiles. The default `instagram-login`
profile uses separate Instagram app credentials, comma-separated `instagram_business_*` scopes,
`api.instagram.com` for the code exchange, and `graph.instagram.com` for calls. It exchanges the
short token for a renewable long-lived token and stores the generic refresh protocol in the
encrypted blob. The optional `facebook-page` profile uses the existing Meta app, Facebook Login,
Page discovery, and a derived Page token. The grants are separate `Secret` rows. See
[instagram-oauth](instagram-oauth.md) for the endpoint matrix and setup contract.
The reusable multi-method rules live in `domain/connections/authorization.py`: capability
ownership, legacy-method inference, method-specific profiles, endpoint-method selection, and scope
translation. `oauth_providers.py` supplies registry data and keeps compatibility methods that
delegate to those domain rules. The initial token exchange is an infrastructure adapter in
`infra/oauth_exchange.py`; `oauth.py` is a compatibility facade only.
`authorization_method` is stored on both `PendingOAuth` and `Secret`. Existing Instagram rows are
backfilled as `facebook-page`. The callback performs token and required identity requests after it
closes the database session. A required identity miss produces `setup_required` and no tool. Its
detail comes from the selected provider profile's `identity_missing_detail`, so the shared callback
contains no Instagram copy. Legacy grant inference also uses
`OAuthProvider.authorization_method_name()` everywhere; shared call and reconnect code does not
repeat provider ids.
The hard part: match every credential shape a real skill uses, keep it encrypted, and keep OAuth tokens
alive, without the proxy ever branching on shape.
@@ -70,6 +97,10 @@ provider that omits `expires_in` doesn't force a refresh on every call), coerces
and raises a clear error when a 200 body carries no `access_token`; `_expires_at` treats a naive ISO
`expiry` as UTC.
Resource discovery and resource selection also call this same `ensure_fresh` implementation before
they close their read session and start provider I/O. They do not implement their own lock, exchange,
or conditional write path.
`refresh()` posts the credential's recorded `client_id_param` dialect (TikTok reads `client_key`, not
`client_id`), snapshotted onto the blob at mint time so a refresh months later still speaks the dialect
the grant was minted with. The same snapshotting covers `token_endpoint_auth_method`: X and Pinterest
@@ -148,10 +179,13 @@ module symbols:
- `get(service)` — look up one provider. `credentials(provider)` — treg's own id/secret (raises if this
deployment hasn't set them). `is_configured(provider)` — whether this deployment can offer it (a
`token`-kind provider needs nothing from treg, so always offerable).
- `listing()` — the marketplace payload (`GET /oauth/providers`): every provider, grouped by
- `listing()` — the catalog payload (`GET /oauth/providers`): every provider, grouped by
`CATEGORY_ORDER`, each flagged `configured`, with per-capability scopes already in plain English via
`scope_label()`/`SCOPE_LABELS` (a lookup keyed by the raw scope string;
`test_every_requested_scope_has_a_plain_english_label` guards it).
`test_every_requested_scope_has_a_plain_english_label` guards it). Authorization methods include
their display label, connect description, and their own configured state. The dashboard and CLI
consume this metadata instead of mapping provider or method ids themselves. A multi-method
provider's top-level `configured` value is true when any declared method is configured.
- `consent_notice` — one line the dashboard shows **before** the consent popup opens, for a provider
whose consent screen names something the user has not seen on treg. Only the Meta family carries one:
the shared Meta app is registered as **Crewlet**, a sibling product of the same company (Superdesign
+14
View File
@@ -70,6 +70,20 @@ related:
# Endpoint catalog — platform-grouped operations per provider
## Authorization metadata
An endpoint can declare `authorization_method`, ordered `authorization_methods`, method-specific
`authorization_paths`, `required_scopes`, `required_resource`, and `token_type`. `_normalize`
keeps these fields on the internal row and exposes them on endpoint detail only when present.
Marketplace resolution uses them for preflight and grant selection. Instagram is the first user;
its 32-row audit is in [instagram-oauth](instagram-oauth.md).
Meta's published reference is not available as a machine-readable OpenAPI document. Reviewed
Instagram `input` and authorization contracts are therefore curated catalog data, and ingestion
carries them forward instead of erasing them on a later scrape.
Instagram is also parameter-multiplexed: profile lookup and business discovery intentionally share
`GET /{ig_user_id}`; the required `fields=business_discovery...` value selects the latter operation.
## Why
The marketplace registry (`oauth_providers.py`) catalogs *credentials*: how to connect a provider.
+7
View File
@@ -34,6 +34,13 @@ related:
# Data model
## OAuth authorization method identity
Revision `0010` adds `authorization_method` to `PendingOAuth` and `Secret`, plus the pending
long-lived exchange style. It backfills existing Instagram secrets as `facebook-page`. New direct
Instagram grants use `instagram-login`. This lets one provider keep two separate grants without
inferring their token type from encrypted data.
SQLModel tables in `src/treg/models.py`. Kept minimal on purpose. Org multi-tenancy adds `Org`,
`Membership`, `Invite` and an `org_id` on the resource nouns — the tenancy mechanics live in
[multi-tenancy](multi-tenancy.md); this fragment is the table reference.
+14 -3
View File
@@ -6,6 +6,7 @@ sources:
- .github/workflows/ci.yml
- src/treg/application/__init__.py
- src/treg/application/call/__init__.py
- src/treg/application/call/access.py
- src/treg/application/call/authorize.py
- src/treg/application/call/idempotency.py
- src/treg/application/call/overflow.py
@@ -27,6 +28,10 @@ sources:
- src/treg/domain/governance/teams.py
- src/treg/domain/governance/usage.py
- src/treg/domain/identity/__init__.py
- src/treg/domain/connections/__init__.py
- src/treg/domain/connections/authorization.py
- src/treg/domain/connections/oauth_flow.py
- src/treg/domain/connections/refresh.py
- src/treg/domain/money/__init__.py
- src/treg/domain/capacity/__init__.py
- src/treg/infra/upstream/__init__.py
@@ -77,11 +82,17 @@ absent until their packages exist, so no placeholder domain makes a future bound
Governance owns shared tool/project ACLs, tag-budget rules, and public-demo rate policy. The package also
forbids direct FastAPI and Starlette imports; semantic policy errors are translated by each HTTP interface.
The call application package owns the framework-neutral staged use case: request intake, idempotency
state, target resolution, marketplace pricing, authorization, reservation, relay orchestration, and
finalization. The HTTP adapter captures a `CallInput`, translates typed failures, and wraps the returned
`UpstreamResponse`. Client-name normalization lives in a neutral leaf so the application path does not
state, target resolution, catalog pricing and access decisions, authorization, reservation, relay
orchestration, and finalization. The HTTP adapter captures a `CallInput`, translates typed failures, and
wraps the returned `UpstreamResponse`. Client-name normalization lives in a neutral leaf so the application path does not
load the Request-aware caller metadata adapter.
The complete `treg.domain.connections` package is also an inward-facing domain package. It owns
provider-neutral authorization-method selection, consent URL construction, and refresh state changes.
It cannot import the API, bootstrap, routers, application layer, FastAPI, or Starlette. Provider registry
data can call these rules through the legacy compatibility modules, while HTTP exchange adapters stay in
`treg.infra` and workflow coordination stays in `treg.application`.
Two runtime contracts keep that boundary executable. `treg.application.call` cannot import the legacy
API, bootstrap, routers, FastAPI, or Starlette. `treg.infra.upstream` cannot import those HTTP adapters
or frameworks. Direct imports of the application-owned request and response DTOs remain the port shared
@@ -0,0 +1,195 @@
---
title: Instagram OAuth — direct Login and optional Facebook Page tools
status: built; Meta configuration and live verification pending
sources:
- scripts/catalog_ingest.py
- src/treg/application/call/access.py
- src/treg/application/call/resolve.py
- src/treg/application/call/service.py
- src/treg/catalog/instagram.yaml
- src/treg/catalog/instagram.extended.yaml
- src/treg/cli.py
- src/treg/domain/catalog/store.py
- src/treg/domain/connections/authorization.py
- src/treg/domain/connections/oauth_flow.py
- src/treg/infra/oauth_exchange.py
- src/treg/mcp.py
- src/treg/routers/call.py
- src/treg/web/index.html
- src/treg/alembic/versions/0010_oauth_authorization_method.py
- tests/test_instagram_oauth_architecture.py
related:
- architecture/auth-secrets.md
- architecture/catalog.md
- architecture/proxy-model.md
- architecture/mcp-oauth.md
- interface/api.md
- interface/cli.md
- interface/dashboard.md
---
# Instagram OAuth
## Contract
`instagram` is one catalog provider with two separate grants:
- `instagram-login` is the default. `treg connections connect --provider instagram` opens
Instagram Login. It uses an Instagram User token and `graph.instagram.com`.
- `facebook-page` is optional. `treg connections connect --provider instagram --capability
page-tools` opens Facebook Login. It uses a Facebook Page token and `graph.facebook.com`. The
selected Instagram Professional account must be linked to that Page.
The grants have separate `Secret` rows, tool names, scopes, tokens, expiry, health, and resource
state. A direct grant does not satisfy a Page-only endpoint. A Facebook Pages connection is also
separate and never creates an Instagram grant.
The provider registry gives the `facebook-page` method a capability intro and incremental benefit
list for its `page-tools` permission card. The card says Page authorization also supports the core
Instagram actions, but lists only the Page-only hashtag, discovery, mention/tag, shopping, and
recent-search tools. The direct `read`, `post`, and `manage` cards retain their ordinary scope-driven
copy. This presentation remains method metadata; the dashboard contains no Instagram-specific branch.
Old Instagram grants used Facebook Login. Migration `0010` marks them as `facebook-page` without
reading token material. Runtime metadata also treats an empty method on an old Instagram row as
`facebook-page`, so rolling upgrades keep working. Shared call and reconnect code asks the provider
registry for this legacy value; neither service contains an Instagram branch.
## Verified endpoint matrix (2026-09-01)
Legend:
- **D** = Instagram Login on `https://graph.instagram.com/v25.0` with an Instagram User token.
- **P** = Facebook Page authorization on `https://graph.facebook.com/v25.0` with the selected
Page token.
- `IG` = the Instagram Professional account id. `Page+IG` = a linked Page and its Instagram
Professional account.
- For a D+P row, the listed `instagram_business_*` scope is the direct scope. The Page profile
maps it to its `instagram_*` equivalent and adds `pages_read_engagement`. Messaging also needs
`pages_messaging`.
| Endpoint ID | Method and path | Grant | Required scope(s) | Resource | Token |
|---|---|---|---|---|---|
| `instagram.instagram.user.profile` | GET `/{ig_user_id}` | D+P | basic | IG | method-specific |
| `instagram.instagram.user.posts` | GET `/{ig_user_id}/media` | D+P | basic | IG | method-specific |
| `instagram.instagram.post.comments` | GET `/{ig_media_id}/comments` | D+P | basic, manage comments | IG | method-specific |
| `instagram.instagram.account.insights` | GET `/{ig_user_id}/insights` | D+P | basic, manage insights | IG | method-specific |
| `instagram.instagram.media.insights` | GET `/{ig_media_id}/insights` | D+P | basic, manage insights | IG | method-specific |
| `instagram.x.user-messages` | GET D `/{ig_user_id}/conversations`; P `/{page_id}/conversations` with `platform=instagram` | D+P | basic, manage messages | IG / Page+IG | method-specific |
| `instagram.instagram.message.send` | POST D `/{ig_user_id}/messages`; P `/{page_id}/messages` | D+P | basic, manage messages | IG / Page+IG | method-specific |
| `instagram.instagram.media.container.create` | POST `/{ig_user_id}/media` | D+P | basic, content publish | IG | method-specific |
| `instagram.instagram.media.container.status` | GET `/{ig_container_id}` | D+P | basic, content publish | IG | method-specific |
| `instagram.instagram.post.publish` | POST `/{ig_user_id}/media_publish` | D+P | basic, content publish | IG | method-specific |
| `instagram.x.comment-delete` | DELETE `/{ig_comment_id}` | D+P | basic, manage comments | IG | method-specific |
| `instagram.x.comment` | GET `/{ig_comment_id}` | D+P | basic, manage comments | IG | method-specific |
| `instagram.x.comment-replies` | GET `/{ig_comment_id}/replies` | D+P | basic, manage comments | IG | method-specific |
| `instagram.x.comment-reply-create` | POST `/{ig_comment_id}/replies` | D+P | basic, manage comments | IG | method-specific |
| `instagram.x.comment-hide` | POST `/{ig_comment_id}` with boolean `hide` | D+P | basic, manage comments | IG | method-specific |
| `instagram.x.media` | GET `/{ig_media_id}` | D+P | basic | IG | method-specific |
| `instagram.x.media-children` | GET `/{ig_media_id}/children` | D+P | basic | IG | method-specific |
| `instagram.x.media-comment-create` | POST `/{ig_media_id}/comments` | D+P | basic, manage comments | IG | method-specific |
| `instagram.x.user-content-publishing-limit` | GET `/{ig_user_id}/content_publishing_limit` | D+P | basic, content publish | IG | method-specific |
| `instagram.x.user-live-media` | GET `/{ig_user_id}/live_media` | D+P | basic | IG | method-specific |
| `instagram.x.user-stories` | GET `/{ig_user_id}/stories` | D+P | basic | IG | method-specific |
| `instagram.x.hashtag-search` | GET `/ig_hashtag_search` | P | `instagram_basic`, `pages_read_engagement` | Page+IG | Page |
| `instagram.x.hashtag` | GET `/{ig_hashtag_id}` | P | `instagram_basic`, `pages_read_engagement` | Page+IG | Page |
| `instagram.x.hashtag-recent-media` | GET `/{ig_hashtag_id}/recent_media` | P | `instagram_basic`, `pages_read_engagement` | Page+IG | Page |
| `instagram.x.hashtag-top-media` | GET `/{ig_hashtag_id}/top_media` | P | `instagram_basic`, `pages_read_engagement` | Page+IG | Page |
| `instagram.x.catalog-product-search` | GET `/{ig_user_id}/catalog_product_search` | P | basic, shopping products, Page read | Page+IG | Page |
| `instagram.x.user-mentioned-comment` | GET `/{ig_user_id}/mentioned_comment` | P | basic, Page read | Page+IG | Page |
| `instagram.x.user-mentioned-media` | GET `/{ig_user_id}/mentioned_media` | P | basic, Page read | Page+IG | Page |
| `instagram.x.user-product-appeal` | GET `/{ig_user_id}/product_appeal` | P | basic, shopping products, Page read | Page+IG | Page |
| `instagram.x.user-recently-searched-hashtags` | GET `/{ig_user_id}/recently_searched_hashtags` | P | basic, Page read | Page+IG | Page |
| `instagram.x.user-tags` | GET `/{ig_user_id}/tags` | P | basic, Page read | Page+IG | Page |
| `instagram.x.user-business-discovery` | GET `/{ig_user_id}` with required `fields=business_discovery...` | P | basic, Page read | Page+IG | Page |
The verified result is 21 endpoints that support both methods and 11 Page-only endpoints. This is
not the earlier assumed 23/9 split.
Every extended Instagram endpoint declares its path, query, and body inputs in the catalog. This is
the contract used by the dashboard form, generated CLI command, API example, and agent prompt. The
comment moderation write accepts `hide=true` and `hide=false`, so the same endpoint hides and unhides.
Official sources used for the audit:
- [Instagram API with Instagram Login](https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-instagram-login/)
- [Instagram API with Facebook Login](https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-facebook-login/)
- [Instagram Login permissions](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login/)
- [Content publishing](https://developers.facebook.com/docs/instagram-platform/content-publishing/)
- [Comment moderation](https://developers.facebook.com/docs/instagram-platform/comment-moderation/)
- [Insights](https://developers.facebook.com/docs/instagram-platform/insights/)
- [Messaging conversations](https://developers.facebook.com/docs/messenger-platform/instagram/features/conversation/)
- [Send messages](https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message/)
- [Hashtag search](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/hashtag-search/)
- [Business discovery](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/business-discovery/)
## Connection and health flow
Instagram Login exchanges the short token for a renewable long-lived token. The refresh metadata
uses `ig_refresh_token` and the Instagram refresh endpoint. The callback then requests
`/me?fields=user_id,username`. It stores that one authorized professional account as the selected
resource and creates the direct tool.
If the identity response has no usable account, the secret remains visible with
`health=setup_required`. No tool is created. The detail tells the user to confirm that the account
is a Business or Creator account and connect again. The provider requests run after the database
session closes.
The Page grant keeps the existing Page and Business discovery. The user selects the linked
Instagram account. Treg derives and stores the Page token inside the encrypted OAuth blob. The
dashboard's **Add account** action presents the two grants as a single-choice method picker, with
direct Instagram Login selected and recommended by default. Selecting it opens the usual
read/post/manage capability picker before consent; the one-capability Page grant continues directly.
Existing connection rows retain their stored method when reconnecting.
Before a catalog call, resolution selects a grant by endpoint provider and authorization method.
It checks token expiry, scopes, and the selected resource before any upstream call. A missing grant
returns HTTP 428 with stable fields: error, provider, endpoint id, method, capability, scopes,
message, CLI command, and dashboard action. Both MCP surfaces return this object unchanged inside
the call result body.
Catalog availability is authorization-method-aware too. A dual-method endpoint is connected when
either compatible grant exists; a Page-only endpoint is connected only when the `facebook-page`
grant exists. The dashboard derives this from each endpoint's `authorization_methods` and each
connection's stored `authorization_method`, rather than special-casing endpoint ids. Its access
dry-run preserves the selected method's registry guidance, including the Page-only
`--capability page-tools` command and action label.
For a dual-method endpoint, callers can select `instagram-login` or `facebook-page`. The dashboard
shows a selector; the CLI uses `--authorization-method`; MCP tools use the
`authorization_method` argument; and direct API calls use `X-Treg-Authorization-Method`. Selection
also controls which path and inputs are shown. If omitted, the endpoint's first declared method is
the deterministic default (Instagram Login for shared endpoints). The control header is consumed by
treg and never relayed upstream. Shared clients accept method ids and display labels from registry
metadata. `POST /oauth/start` returns the selected method description as `connect_guidance`, which
lets the CLI explain the grant without provider-specific code.
When several stored grants match one method, catalog fallback selects the newest row. Tool-grant
resolution loads the provider's secrets once and matches bindings from that set, rather than loading
one secret for every binding.
## Meta configuration and live verification
No repository task changes Meta settings. A human must do these steps:
1. Open [Meta App Dashboard](https://developers.facebook.com/apps/) and select the app that will
own Instagram Login.
2. Add or open **Instagram > API setup with Instagram login > Business login settings**.
3. Set the production redirect URI to `https://treg.to/oauth/callback`. For local testing, set
`https://<your-tunnel-host>/oauth/callback` only while that tunnel is active.
4. Copy the separate Instagram App ID and Instagram App Secret into local `.env` as
`TREG_INSTAGRAM_CLIENT_ID` and `TREG_INSTAGRAM_CLIENT_SECRET`. Do not put these values in chat or
source control.
5. Request Advanced Access for `instagram_business_basic`,
`instagram_business_manage_insights`, `instagram_business_content_publish`,
`instagram_business_manage_comments`, and `instagram_business_manage_messages` when users will
connect accounts that the app owner does not manage.
6. Keep the existing Meta app credentials in `TREG_META_CLIENT_ID` and
`TREG_META_CLIENT_SECRET` for `page-tools`. Confirm its Page and Instagram permissions in App
Review, including `instagram_shopping_tag_products` if product tools remain enabled.
7. Verify one Business account and one Creator account with no linked Page through the direct flow.
Then verify a linked Page account through `page-tools`. Run a safe read from each class. Test
publishing and messaging only with deliberate test content and recipients.
App Review is required for production access to accounts that the app owner does not own or
manage. Development-mode tests with app-role accounts do not prove that review is complete.
+12
View File
@@ -23,6 +23,14 @@ related:
# MCP
## Provider authorization remediation
Provider OAuth remains a human browser action. A catalog-call preflight can return a structured
HTTP 428 body with the missing or expired grant, capability, scopes, exact CLI command, and safe
dashboard action. The team MCP `call` tool and directory MCP `catalog_call_read` or
`catalog_call_write` preserve that body. The agent can tell the human what to authorize and retry
the same endpoint after consent. No MCP response includes a credential.
Everything else in treg is reached by a CLI or an HTTP call. These are the doors an **assistant**
comes through: ChatGPT, Claude, Claude Code, Cursor, or anything else that speaks the Model Context
Protocol. Both use one deployment, database, and enforcement layer:
@@ -500,6 +508,10 @@ header. `curl {BASE}/install.sh | sh -s -- --token <key>` runs the whole thing
## Caller tags over MCP
Catalog-call tools also expose an optional `authorization_method`. MCP maps that explicit argument
to treg's internal `X-Treg-Authorization-Method` routing header; caller-supplied headers cannot
override it, and the header is not relayed to the provider.
`X-Treg-Meta` (see [money](money.md)) is read off the MCP **transport** in `mcp.call()` and forwarded
on the internal request, the same way `catalog_request` forwards `X-Forwarded-For`. It is deliberately
**not** a tool argument: a model asked to pass a customer id will omit it somewhere in a chain, and a
+7
View File
@@ -38,6 +38,13 @@ related:
# The proxy (the whole product in one function)
## Same-host provider grants
Named catalog calls with authorization metadata resolve by endpoint provider and stored grant
method before they compare upstream hosts. This prevents a Facebook tool and a Page-backed
Instagram tool on `graph.facebook.com` from producing `target_ambiguous`. The selected tool and
binding then enter the normal relay. The relay has no Meta or Instagram branch.
The relay is `relay()` in `src/treg/infra/upstream/relay.py`.
`application.call.resolve` resolves which tool or
marketplace endpoint a request targets, and the call path loads its secrets; `relay()` injects and
+21 -9
View File
@@ -1,8 +1,11 @@
---
title: Expanding a marketplace category — the add-a-provider playbook
title: Expanding a catalog category — the add-a-provider playbook
status: guide
sources:
- src/treg/oauth_providers.py
- src/treg/domain/connections/authorization.py
- src/treg/domain/connections/oauth_flow.py
- src/treg/infra/oauth_exchange.py
- src/treg/application/connect.py
- src/treg/routers/connections.py
- src/treg/config.py
@@ -11,7 +14,7 @@ related:
- interface/api.md
---
# Expanding a marketplace category (adding providers)
# Expanding a catalog category (adding providers)
How we grew **SEO**, **Enrichment** and **Advertising** from a handful of entries to ten each — and then
Enrichment again by eight providers in one pass (2026-08-20: companyenrich, oceanio, tomba, predictleads,
@@ -20,9 +23,11 @@ category needs more providers. Creator/influencer data (influencers.club, 2026-0
provider added under the vendor-listing skill end to end: registry + 15-endpoint catalog, every price
reconciled against the provider's own credit meter.
Everything lives in **`oauth_providers.py`** (the `REGISTRY` of `OAuthProvider` entries). Connecting,
verifying and auto-provisioning a pasted-key provider is **`connect_with_token`** (`POST /connections/token`)
in `routers.connections`, backed by `application.connect`. Both are documented in
Provider definitions and setup metadata live in **`oauth_providers.py`** (the `REGISTRY` of
`OAuthProvider` entries). Reusable authorization and consent rules live in `domain/connections`, and
token endpoint I/O lives in `infra/oauth_exchange.py`. Connecting, verifying, and auto-provisioning a
pasted-key provider is **`connect_with_token`** (`POST /connections/token`) in
`routers.connections`, backed by `application.connect`. These parts are documented in
[auth-secrets](../architecture/auth-secrets.md) + [api](../interface/api.md);
this fragment is the *process*, not the mechanics reference.
@@ -45,7 +50,7 @@ this fragment is the *process*, not the mechanics reference.
because you validate them **live**. Have the research agent say "unconfirmed" rather than guess a path.
3. **Add the registry entry** — an `OAuthProvider(auth_kind="key", …)` in `oauth_providers.py`; add it to the
`REGISTRY` tuple; add the category to `CATEGORY_ORDER` if it's new. Key providers have `scopes={}` (no
consent screen); the marketplace card leans on `summary`.
consent screen); the catalog card uses `summary`.
4. **Placeholder logo** — `web/logos/<service>.svg` (a lettermark; swap for the official brand SVG later). The
guard test `test_every_provider_has_a_logo` fails without one. Do NOT reproduce a real brand mark — a neutral
lettermark is the placeholder.
@@ -59,7 +64,7 @@ this fragment is the *process*, not the mechanics reference.
This one step caught Apollo (`is_logged_in`), Akta (trailing slash), Majestic (`Code`), and ScrapeCreators
(accepts any key). **Never ship a key provider you haven't watched reject a bogus key.** Run in a throwaway
org and delete it after (test users are `e2e-…@treg.local`).
7. **Run the suite, [sync docs](../../../.claude/skills/tools-registry-context/MAINTAINING.md), commit + push.**
7. **Run the suite, [sync docs](../../../.agents/skills/tools-registry-context/MAINTAINING.md), commit + push.**
## The verify toolbox — which `OAuthProvider` field for which bad-key behavior
@@ -126,6 +131,13 @@ rejects on HTTP status by default.
unconfigured OAuth platform — don't force a bad-fit provider.
## The heavy path — adding an OAuth provider
One logical provider can have more than one explicit grant. Use `authorization_methods` with
capability ownership and protocol overrides; do not duplicate the provider catalog. Store the
selected method on the pending flow and secret. Add endpoint authorization metadata so catalog
resolution selects by provider and grant identity before it compares shared upstream hosts.
Instagram direct Login plus optional Facebook Page tools is the reference implementation; see
[instagram-oauth](../architecture/instagram-oauth.md).
1. treg must register its **own** dev app on the network → `client_id`/`client_secret` → add the two settings
to `config.py` (`Settings`) so they load from env; the registry entry names them via
`client_id_setting`/`client_secret_setting`.
@@ -145,8 +157,8 @@ rejects on HTTP status by default.
`resource_example` to stamp a ready-made call onto the tool once the user picks their resource.
5. **NON-STANDARD OAuth is not free.** TikTok Ads (`app_id`/`auth_code`, JSON-body token exchange, `code==0`
envelope, `Access-Token` header instead of `Authorization: Bearer`) does NOT work with the standard
`oauth.py` flow or the Bearer auto-provision binding. Add it as a **flagged placeholder** — it needs real
`oauth.py` + binding work before it can run.
shared connection flow or the Bearer auto-provision binding. Add it as a **flagged placeholder** — it
needs a protocol adapter and binding work before it can run.
## The tests that gate every new provider
- `test_every_provider_is_registered` — add the id.
+19 -1
View File
@@ -9,6 +9,7 @@ sources:
- src/treg/caller_metadata.py
- src/treg/client_identity.py
- src/treg/application/auth.py
- src/treg/application/call/access.py
- src/treg/application/call/authorize.py
- src/treg/application/call/idempotency.py
- src/treg/application/call/intake.py
@@ -59,6 +60,22 @@ related:
# The API
## Instagram authorization strategy
`POST /oauth/start` keeps the same request shape. `provider=instagram` defaults to direct
Instagram Login. `capability=page-tools` selects the separate Facebook Page profile. The start
response returns `state`, `consent_url`, `redirect_uri`, and `connect_guidance`. The guidance is the
selected authorization method's registry description, so clients do not need provider-specific
setup text. Connection rows now include `authorization_method`, its label, method-specific resource
discovery metadata, and setup health.
Catalog calls accept `X-Treg-Authorization-Method` to select one of an endpoint's declared methods;
the header is an internal routing control and is stripped before the faithful upstream relay.
Catalog calls can fail before relay with HTTP 428. Its `detail` object has a stable error code,
provider, endpoint id, required method, capability and scopes, explanation, CLI command, and
dashboard action. This distinguishes a missing grant, missing resource, missing scope, and expired
grant. See [instagram-oauth](../architecture/instagram-oauth.md).
`api.router` preserves public registration order while concern routers contribute ordered route blocks.
`bootstrap.create_app()` assembles the combined route table into FastAPI roles.
`api.app` remains the deployed, backward-compatible `all` role. Everything the CLI + skill do is one
@@ -562,7 +579,8 @@ validated before resolving the shared HTTP client. `/auth/logout` remains an HTT
not even fetched: the captured evidence is admin-only in v1, and putting it on a team's own feed
has to be a deliberate edit in two places rather than a column appearing by accident.
- **OAuth connect + the provider marketplace:** `oauth_start` (`POST /oauth/start`) creates a
`PendingOAuth` and returns `consent_url` + `state` + `redirect_uri`; `oauth_callback`
`PendingOAuth` and returns `consent_url` + `state` + `redirect_uri` + registry-owned
`connect_guidance`; `oauth_callback`
(`GET /oauth/callback`, open) exchanges the code and creates/updates the oauth secret; `oauth_status`
polls. **Two modes** (`OAuthStartIn`): **BYO** (supply `client_id`/`client_secret`/`auth_uri`/
`token_uri`/`scopes`) or **REGISTRY** (supply `provider` + optional `capability`) where treg fills
+16
View File
@@ -12,6 +12,22 @@ related:
# The `treg` CLI
## Instagram grants
`treg connections connect --provider instagram` starts direct Instagram Login, prints the consent
URL, and polls the normal status endpoint. Page-only tools use `--capability page-tools`. Before
that consent starts, `POST /oauth/start` returns the selected authorization method's registry
description and the CLI prints it. The CLI contains no provider-specific guidance. Call errors
return the exact command for the missing method.
For a catalog endpoint that supports both grants, `treg call <endpoint>
--authorization-method <method>` selects the grant, upstream host, route, and method-specific
required inputs. The CLI accepts any method name; the API validates it against endpoint metadata.
Omitting it uses the endpoint's first declared method.
For a dual-method endpoint, omitting it first uses the sole connected grant when there is one;
otherwise the ordered default is Instagram Login. Static catalog templates therefore omit the flag
for dual-method endpoints, while dashboard-generated commands include the user's resolved choice.
A thin client over the API in `src/treg/cli.py` — every command is one HTTP call, no logic of its own
(stdlib `argparse`, reuses `httpx`). Entry point `main()` → `build_parser()` → dispatch to a `cmd_*`.
Every command/subcommand carries a `description` + `help` on each argument + a copy-paste **Examples**
+53 -11
View File
@@ -24,6 +24,27 @@ related:
# Web dashboard (Phase 1)
## Instagram authorization state
The primary **Add account** action opens one method picker for providers with several separate OAuth
grants. For Instagram it selects **Instagram Login** by default and marks it recommended; the user
can instead select **Facebook Page tools**, whose option explains that it requires an Instagram
Professional account linked to a Facebook Page, then one **Continue** action starts the selected
flow. Choosing Instagram Login then opens the existing least-privilege capability picker for
**Read only**, **Read and publish**, or **Full access**; the single-capability Facebook Page grant
continues directly. Providers with zero or one authorization method keep their existing
one-click/capability flow. Reconnect stays pinned to the connection's stored method. Method labels
come from `oauth_providers.listing()`; the shared template does not know method ids. The registry
marks a provider configured when any declared method is configured, and the picker selects the
recommended method only when that method is available.
The provider page has no method-status alert: healthy and optional states live in the connection
rows and permission cards, while alert styling remains reserved for real errors or setup gaps. Each
row retains the shared production layout: connection name and account identity in the first column,
generated tool name in the second, then health/capabilities and actions. Method-specific resource
discovery is unchanged. A direct identity miss shows `setup required`; it does not show the
connection as working and no direct tool exists.
A single-file Vue 3 dashboard in `src/treg/web/index.html`, served **same-origin** by the API
(`GET /app` → `FileResponse`, `dashboard()` in `routers.web`, via `_WEB_DIR`). Same origin = no CORS and it
ships with the server (Render/Fly). Design language: **Ledger** (warm charcoal + clay accent,
@@ -347,7 +368,10 @@ provider account (Google Analytics, Search Console, Google Tag Manager, Google A
TikTok, LinkedIn, YouTube, …) without touching the CLI. `loadConnections` fetches **`GET /oauth/providers`**
(server route `oauth_providers_list` → `oauth_providers.listing()`, each row carrying `service`,
`display_name`, `category`, `summary`, `capabilities`, `scope_detail`, `auth_kind`, `supports_discovery`,
and a **`configured`** flag = whether *this* deployment holds the provider's client credentials) plus
and a **`configured`** flag = whether *this* deployment can run at least one connect flow). Each
authorization method also has its own `configured` flag. For a multi-method provider, the registry
sets the provider flag when any one method is available, so a configured secondary grant cannot be
hidden by an unavailable primary grant. The payload is loaded with
**`GET /connections`** (`list_connections` — the org's existing grants).
The list view opens on a **tab bar** (`.mk-tabs`, `mkTabs` computed): `All`, then **one tab per catalog
@@ -396,7 +420,9 @@ og meta, since the page is only meaningful to a signed-in member; client route `
push `/app/marketplace/<service>` into history). It lists the connected accounts (each account = its own
tool name so an agent can call a specific one), their health/expiry chips, and a **Permissions** panel
(`mkGranted` marks which capabilities are already granted; `scope_detail` gives the exact upstream scopes
on hover).
on hover). A method may optionally provide `capability_intros` and `capability_details`; the shared
renderer uses them for incremental-benefit copy and falls back to `scope_detail` for every ordinary
capability. This lets one grant explain what it uniquely adds without provider-specific template logic.
**Consent disclosure.** A provider row may carry a **`consent_notice`**, rendered as a `.mk-notice` panel
in two places: under the summary on the integration page (beside Connect) and inside the `capAsk` modal,
@@ -644,17 +670,20 @@ not arbitrary public accounts", while `any_account` scraper rows read a muted `a
sit the parameters block, the provider-wide facts (`epFacts`: the cost note, `limits` and the rate card,
served once per provider in the response's `providers` map rather than copied onto 2,000 rows), the
paste-ready **`treg call`** line the row carries as `call_template` with a Copy button, the docs link and
the lazy example toggle. **Connection awareness** reuses the marketplace's own state: `catConnected` reads
`connCount` (from `/connections`), a row with a connected provider carries a green rule down its leading
edge (`.lrow.go td:first-child`), and the **Connect** button deep-links to the provider's existing
integration page — shown only when `mkKnown(service)`, since the catalog can name a provider this
deployment carries no client credentials for.
the lazy example toggle. **Connection awareness** reuses `/connections`, but endpoint rows intersect
their declared `authorization_methods` with each connection's stored `authorization_method`; having one
grant for a provider therefore cannot mark an endpoint that requires a different grant as connected.
Providers without multiple authorization methods retain the provider-level `catConnected` behavior.
The endpoint-aware label names the sole required method when one exists, but the **Connect** action
always navigates to the provider connection page; consent never starts unexpectedly inside the catalog
ledger. It is shown only when `mkKnown(service)`, since the catalog can name a provider this deployment
carries no client credentials for.
**The runnable green.** `.chip.ok` is not styled anywhere in the file, so it renders as muted grey — which
is how a ready capability came to look identical to an unavailable one. `.chip.go` (+ the haloed `.godot`)
is the marketplace's single "you can call this right now" green, used in exactly three places: the
platform card's `Connected` corner, a connected provider's `connected` chip, and a ledger row whose
provider is connected, which carries the green as a rule down its leading edge (`.lrow.go td:first-child`)
platform card's `Connected` corner, a compatible endpoint grant's `connected` chip, and a ledger row
with a compatible grant, which carries the green as a rule down its leading edge (`.lrow.go td:first-child`)
so it survives being skimmed. Everything unconnected stays muted.
An expanded row leads with the endpoint's chips and summary, then splits into **two tabs**: **Request**
@@ -681,8 +710,11 @@ ghost beside it. The same `goByok` jump is offered from a platform page's provid
provider only when the platform has exactly one) and from the Try-it drawer's "can't run this here"
banner, which now carries a real **Connect** / **Bring your own key** button instead of prose alone. For an **OAuth** provider treg *can't* serve on
its own key (calls act as your account), so the order flips: **Connect {provider}** is the ink-fill
primary and Try-it is secondary. Once an account is connected the connect/own-key button is replaced in
place by the green **`Connected`** chip. Everything here renders identically in a single row's expansion
primary and Try-it is secondary. Once a compatible account method is connected the connect/own-key button
is replaced in place by the green **`Connected`** chip. A missing method uses the registry's action label
and missing-message, then routes the user to the provider connection page. The exact CLI connect command
remains in the access response for CLI and agent consumers, but is not rendered in the Manual banner.
Everything here renders identically in a single row's expansion
and in a merged row's provider sub-row, because both paths share the one `.lep` block.
**The Try-it drawer (`epTry`) is four tabs** (`epTryTab`, default **AI Agent**): **AI Agent** — the
@@ -697,6 +729,14 @@ that has a body;
and **Manual** — the live test form (params + `❯ Run`, disabled with a reason when the access dry-run
says this org can't call it) that the drawer used to be by itself.
When an endpoint supports more than one authorization method, the drawer shows one explicit method
selector. Changing it swaps the visible method-specific inputs and updates the AI Agent, CLI, API,
and direct-call representations together. The selector label comes from the provider registry,
not a method-id lookup in the template. Instagram Login is the first/default method on shared
Instagram endpoints. The selector appears only when both grants are connected; one connected grant
is selected automatically, while no connected grant keeps the recommended Instagram Login default
and the ordinary catalog access guidance.
**Example responses** load when their tab is FIRST opened (`setEpTab` → `loadExample`, guarded by
`if(this.platEx[e.id]) return`), never with the page and never twice — a platform can carry hundreds of
endpoints and the captured responses are the heaviest thing in the catalog.
@@ -714,6 +754,8 @@ background, border, radius, filled header bar), which otherwise reads as a stray
`.prm` box and clips the first column against the table's own border. Navigation runs both ways: an integration page carries a
**Covered in the catalog** chip row (`mkPlatforms`) into the platform pages, and each platform page
header links back out to the providers that serve it (`platProviders`). `tests/test_dashboard_markup.py`
pins this provider navigation to the platform response itself; it does not disappear while the
separate OAuth connection registry is still loading. The same test
locks the structure (top-level view, the row/detail `<template>` pair inside the `.ttable`, the
`v-if`'d tab bar and its `platform` fallback, the derived tab list and category order, tiles wearing the
platform's own logo with the generated-initial fallback, the `Platform` tab still carrying the provider
+2 -1
View File
@@ -106,7 +106,8 @@ bound to a closed maintenance loop. Calling `maintenance.upgrade()` directly doe
`google_ads_developer_token` (treg's token from OUR approved manager account, injected on every Ads
call as a **platform binding** — see [proxy-model](../architecture/proxy-model.md)). The other
providers each take a `<name>_client_id`/`_secret` pair: `linkedin_*`, `slack_*`, `x_*`, `tiktok_*`
(separate sandbox vs prod app), `meta_*` (ONE Meta app backs both facebook + instagram), and the
(separate sandbox vs prod app), `meta_*` (Facebook Pages, Meta Ads, and Instagram Page tools),
and `instagram_*` (the separate Instagram App ID and secret for direct Instagram Login), and the
Advertising OAuth platforms `microsoft_ads_*`, `snapchat_ads_*`, `tiktok_ads_*`, `pinterest_*` (all
unset by default, so those providers ship **unconfigured** until a deployment registers a dev app).
Empty for a provider ⇒ it lists as **unconfigured** rather than failing part-way through a consent.
+15
View File
@@ -167,6 +167,21 @@ forbidden_modules = ["treg.api", "treg.bootstrap", "treg.routers", "fastapi", "s
allow_indirect_imports = true
as_packages = true
[[tool.importlinter.contracts]]
name = "Connections domain does not depend on outer layers"
type = "forbidden"
source_modules = ["treg.domain.connections"]
forbidden_modules = [
"treg.api",
"treg.bootstrap",
"treg.routers",
"treg.application",
"fastapi",
"starlette",
]
allow_indirect_imports = true
as_packages = true
[[tool.importlinter.contracts]]
name = "Catalog domain is a leaf: no outer layers, no sibling domains"
type = "forbidden"
+11 -5
View File
@@ -157,7 +157,7 @@ def core_routes(provider: str) -> set[tuple[str, str]]:
def carry_verification(provider: str, endpoints: list[dict]) -> int:
"""Re-attach `verified` / `example_response` / `unverified` / `capability` from the file being replaced.
"""Re-attach reviewed and live-verified fields from the file being replaced.
Those fields are the only ones NOT derived from upstream: verification stamps are the result
of an actual paid call made by scripts/catalog_verify_extended.py, and `capability` is a
@@ -192,7 +192,13 @@ def carry_verification(provider: str, endpoints: list[dict]) -> int:
continue
if prev.get("method") != ep.get("method") or prev.get("path") != ep.get("path"):
continue
for field in ("verified", "example_response", "unverified", "capability", "name", "kind"):
for field in (
"verified", "example_response", "unverified", "capability", "name", "kind",
# Meta publishes no machine-readable request schema or grant matrix. These contracts
# are reviewed against its HTML docs and must survive the next deterministic ingest.
"input", "authorization_method", "authorization_methods", "authorization_paths",
"required_scopes", "required_resource", "token_type",
):
if prev.get(field) is not None:
ep[field] = prev[field]
if prev.get("capability") is not None and prev.get("platform"):
@@ -1905,7 +1911,7 @@ INSTAGRAM_EDGES: list[tuple[str, str, str, str, str]] = [
"A comment that @-mentions this account, with its thread", ""),
("user-content-publishing-limit", "GET", "/{ig_user_id}/content_publishing_limit",
"How many of the 24-hour posting quota (50 posts) this account has already used", ""),
("user-business-discovery", "GET", "/{ig_user_id}?fields=business_discovery.username({username})",
("user-business-discovery", "GET", "/{ig_user_id}",
"Read ANOTHER public professional account's followers, media count and recent posts", ""),
("user-recently-searched-hashtags", "GET", "/{ig_user_id}/recently_searched_hashtags",
"The hashtags this account has looked up recently (the 30-per-week search quota)", ""),
@@ -1927,8 +1933,8 @@ INSTAGRAM_EDGES: list[tuple[str, str, str, str, str]] = [
"The replies under a comment", ""),
("comment-reply-create", "POST", "/{ig_comment_id}/replies",
"Reply to a comment as the account", ""),
("comment-hide", "POST", "/{ig_comment_id}?hide=true",
"Hide a comment on the account's own media", ""),
("comment-hide", "POST", "/{ig_comment_id}",
"Hide or unhide a comment on the account's own media", ""),
("comment-delete", "DELETE", "/{ig_comment_id}",
"Delete a comment on the account's own media", ""),
("media-comment-create", "POST", "/{ig_media_id}/comments",
+4 -1
View File
@@ -374,7 +374,10 @@ def main(argv: list[str]) -> int:
# `dataset_id`, GAQL's query body), so a core/extended path collision is legitimate THERE and
# only there. Everywhere else it is the ingest-dedup failure that shipped 27 duplicate rows
# (dataforseo via the /v3 prefix mismatch, scrapecreators via core being curated after ingest).
PARAM_MULTIPLEXED = {"serpapi", "brightdata", "google-ads"}
# Some APIs deliberately expose several catalog jobs on one method/path and select the job by
# required parameters. Instagram Graph's profile read and business_discovery both call
# GET /{ig_user_id}; the latter is selected by its required business_discovery `fields` value.
PARAM_MULTIPLEXED = {"serpapi", "brightdata", "google-ads", "instagram"}
route_tiers: dict[tuple, dict[str, list[str]]] = {}
for path in files:
name = path.name
@@ -0,0 +1,57 @@
"""separate OAuth authorization methods on pending and stored grants
Revision ID: 0010
Revises: 0009
Create Date: 2026-09-01
Existing Instagram grants were created only through Facebook Login, so the backfill can identify
them without inspecting token material. Empty remains the compatibility value for every provider
whose one method predates this column.
Rollback floor: downgrading removes the authorization-method identity after new direct Instagram
grants may have been written, so an older build could mistake them for legacy Facebook Page grants.
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "0010"
down_revision: str | Sequence[str] | None = "0009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
contract = True
def upgrade() -> None:
op.add_column(
"pendingoauth",
sa.Column("authorization_method", sa.String(), nullable=False, server_default=""),
)
op.add_column(
"pendingoauth",
sa.Column("long_lived_exchange_style", sa.String(), nullable=False, server_default=""),
)
op.add_column(
"secret",
sa.Column("authorization_method", sa.String(), nullable=False, server_default=""),
)
op.create_index(
op.f("ix_secret_authorization_method"), "secret", ["authorization_method"], unique=False
)
op.execute(
sa.text(
"UPDATE secret SET authorization_method = 'facebook-page' "
"WHERE provider = 'instagram' AND authorization_method = ''"
)
)
def downgrade() -> None:
with op.batch_alter_table("secret") as batch:
batch.drop_index(op.f("ix_secret_authorization_method"))
batch.drop_column("authorization_method")
with op.batch_alter_table("pendingoauth") as batch:
batch.drop_column("long_lived_exchange_style")
batch.drop_column("authorization_method")
+226
View File
@@ -0,0 +1,226 @@
"""Catalog endpoint access decisions.
The HTTP router translates this use case. It does not decide how grants, tools, credentials, or
platform service tiers satisfy an endpoint.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from ... import oauth_providers
from ...config import get_settings
from ...domain import money as ledger
from ...domain.catalog import store as catalog_store
from ...domain.connections import authorization as connection_authorization
from ...domain.identity.access import Caller
from .resolve import (
_authorization_method,
_enforce_catalog_status,
_marketplace_secret,
_platform_estimate_micro,
_platform_offer,
_provider_tool_grant,
_resolve_call,
resolve_call_target,
)
from .route import RouteOptions, build_plan
from .types import CallFailure, ResolutionFailed
async def catalog_endpoint_access(
*, endpoint_id: str, authorization_method: str, caller: Caller, db: AsyncSession,
) -> dict:
"""Return the catalog service tier and authorization that can serve one endpoint."""
catalog = catalog_store.load()
endpoint = catalog.by_id.get(endpoint_id)
if endpoint is None:
raise ResolutionFailed(
"unknown_endpoint", status_code=404,
detail=f"unknown endpoint {endpoint_id!r}",
)
_enforce_catalog_status(endpoint)
service = endpoint["provider"]
if endpoint.get("kind") == "routed":
return await _routed_access(endpoint, caller, catalog)
registry_provider = oauth_providers.get(service)
if registry_provider is None or not registry_provider.base_url:
return {"tier": "none", "detail": f"{service} isn't proxy-callable yet"}
try:
methods = connection_authorization.select_endpoint_methods(
endpoint, authorization_method,
)
except ValueError as exc:
raise ResolutionFailed(
"catalog_parameter_invalid", status_code=400, detail=str(exc),
) from None
provider = (
registry_provider.profile_for_authorization(methods[0])
if authorization_method.strip() and methods else registry_provider
)
billed_note = _billed_note(endpoint, provider, service, catalog)
if methods:
try:
grant = await _provider_tool_grant(service, methods, caller, db)
except CallFailure as exc:
if exc.status_code == 403:
return {
"tier": "restricted",
"detail": (
"a connected account exists but your access is restricted — ask an admin"
),
}
raise
if grant is not None:
tool, _, grant_method = grant
return {
"tier": "tool",
"authorization_method": grant_method,
"metered": bool(billed_note),
"detail": f"will use this org's registered {tool.name!r} tool{billed_note}",
}
secret = await _marketplace_secret(service, caller.org_id, db, methods)
if secret is not None:
return {
"tier": "credential",
"authorization_method": _authorization_method(secret),
"metered": bool(billed_note),
"detail": f"will use this org's {service} credential (no tool needed){billed_note}",
}
else:
direct = await _direct_access(endpoint, provider, service, caller, db, billed_note)
if direct is not None:
return direct
cost = _platform_offer(endpoint, provider, caller.org)
if cost is not None:
estimate = _platform_estimate_micro(cost, {})
return {
"tier": "platform",
"detail": (
f"no key needed — uses treg's {service} key, "
f"~${ledger.usd(estimate):g}/call from your team balance (treg balance)"
),
"estimated_cost_micro": estimate,
"estimated_cost_usd": ledger.usd(estimate),
}
return _missing_access(endpoint, registry_provider, provider, methods, service)
async def _routed_access(endpoint: dict, caller: Caller, catalog) -> dict:
options = RouteOptions.from_headers(lambda key: None)
plan = await build_plan(
endpoint, dict(endpoint.get("test_request", {}).get("body") or {}), caller, options,
)
if not plan.candidates:
contract = catalog.contracts.get(endpoint.get("capability") or "")
for variant in (contract.identity if contract else []):
trial = await build_plan(
endpoint, {key: "example" for key in variant}, caller, options,
)
if trial.candidates:
plan = trial
break
if not plan.candidates:
return {
"tier": "none",
"detail": (
"no provider can serve any identity shape of this job for your team right now"
),
"dropped": plan.dropped,
}
first = plan.candidates[0]
how = (
"your registered tool" if first.tier == "tool" else
"your own credential" if first.tier == "credential" else
f"treg's {first.endpoint['provider']} key, ~${(first.price_micro or 0) / 1e6:g}"
)
dropped_note = ""
if plan.dropped:
dropped_note = (
"; for this {" + ", ".join(plan.variant) + "} example, not usable: "
+ ", ".join(
f"{item['endpoint_id']} ({item['why']})" for item in plan.dropped
)
)
return {
"tier": "routed",
"detail": (
f"routed — {len(plan.candidates)} providers callable now; first: "
f"{first.endpoint['id']} on {how} (send {{{', '.join(first.variant)}}})"
+ dropped_note
),
"plan": [candidate.view() for candidate in plan.candidates],
"dropped": plan.dropped,
}
def _billed_note(endpoint: dict, provider, service: str, catalog) -> str:
if not (provider.platform_billed and service in get_settings().oauth_billed_set):
return ""
cost = catalog.cost_view(endpoint.get("cost"), service) if endpoint.get("cost") else None
estimate = _platform_estimate_micro(cost, {}) if cost and cost.get("usd") else 0
if estimate:
return (
f" — metered from the team balance (~${ledger.usd(estimate):g}/call: "
f"{service} bills treg's app per use)"
)
return f" — metered from the team balance ({service} bills treg's app per use)"
async def _direct_access(
endpoint: dict, provider, service: str, caller: Caller, db: AsyncSession,
billed_note: str,
) -> dict | None:
probe = provider.base_url.rstrip("/") + "/" + (endpoint["path"] or "/").lstrip("/")
try:
target = await resolve_call_target(probe, caller, _resolve_call)
return {
"tier": "tool",
"metered": bool(billed_note),
"detail": (
f"will use this org's registered {target.tool.name!r} tool{billed_note}"
),
}
except CallFailure as exc:
if exc.status_code == 403:
return {
"tier": "restricted",
"detail": "a registered tool exists but your access is restricted — ask an admin",
}
if exc.status_code != 404:
raise
if await _marketplace_secret(service, caller.org_id, db) is not None:
return {
"tier": "credential",
"metered": bool(billed_note),
"detail": f"will use this org's {service} credential (no tool needed){billed_note}",
}
return None
def _missing_access(
endpoint: dict, registry_provider, provider, methods: tuple[str, ...], service: str,
) -> dict:
specification = (
connection_authorization.method_spec(registry_provider, methods[0]) if methods else None
)
capability = specification.connect_capability if specification else ""
connect = f"treg connections connect --provider {service}"
if capability:
connect += f" --capability {capability}"
hint = (
f"connect with: {connect}" if not provider.uses_pasted_secret else
f"connect with: {connect}, or treg secret add {service} …"
)
return {
"tier": "none",
"authorization_method": methods[0] if methods else "",
"connect_capability": capability,
"connect_command": connect,
"action_label": specification.action_label if specification else "",
"missing_message": specification.missing_message if specification else "",
"detail": f"no {service} credential in this org yet — {hint}",
}
+219 -39
View File
@@ -12,12 +12,14 @@ from urllib.parse import quote, urlsplit
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from ... import oauth_providers
from ... import oauth, oauth_providers
from ... import sandbox as demo_sandbox
from ...config import get_settings, platform_setting_name
from ...domain.capacity.routes_view import view as overflow_routes_view
from ...domain.capacity.view import view as capacity_view
from ...domain.catalog import store as catalog_store
from ...domain.connections import authorization as connection_authorization
from ...domain.connections.refresh import expiry_state
from ...domain.governance import access as access_policy
from ...domain.identity.access import Caller
from ...infra.db import session_maker
@@ -26,6 +28,9 @@ from ..connect import _host_of, _provider_bindings
from .types import ResolutionFailed, ResolvedTarget
AUTHORIZATION_METHOD_HEADER = "X-Treg-Authorization-Method"
@dataclass(frozen=True)
class QueryValues:
items: tuple[tuple[str, str], ...]
@@ -238,9 +243,34 @@ def _enforce_catalog_status(ep: dict) -> None:
raise ResolutionFailed("catalog_retired", status_code=410, detail=detail)
async def _marketplace_secret(service: str, org_id: int, db: AsyncSession) -> Secret | None:
def _authorization_method(secret: Secret) -> str:
"""Stored grant method, including the provider's declared legacy inference."""
provider = oauth_providers.get(secret.provider) if secret.provider else None
return (
provider.authorization_method_name(secret.authorization_method)
if provider else secret.authorization_method
)
async def _marketplace_secret(
service: str, org_id: int, db: AsyncSession, methods: tuple[str, ...] = (),
) -> Secret | None:
"""Tier 2's credential: an org secret tagged with this provider (registry connects), else one
NAMED exactly for it (`treg secret add tikhub …`). Newest wins — a reconnect supersedes."""
if methods:
tagged_rows = (await db.execute(
select(Secret).where(Secret.org_id == org_id, Secret.provider == service)
.order_by(Secret.id.desc())
)).scalars().all()
# Rows are newest first. Preserve the first row for each method; assigning every row into
# a comprehension would let the oldest reconnect overwrite the newest one.
by_method: dict[str, Secret] = {}
for row in tagged_rows:
by_method.setdefault(_authorization_method(row), row)
for method in methods:
if method in by_method:
return by_method[method]
return None
tagged = (await db.execute(
select(Secret).where(Secret.org_id == org_id, Secret.provider == service)
.order_by(Secret.id.desc())
@@ -715,11 +745,24 @@ def _marketplace_no_credential(
_VALID_PERCENT_ESCAPE_RE = re.compile(r"%[0-9A-Fa-f]{2}")
def _marketplace_upstream(ep: dict, provider, query_params) -> tuple[str, set[str]]:
def _marketplace_upstream(
ep: dict, provider, query_params, authorization_method: str = "",
) -> tuple[str, set[str]]:
"""The full upstream URL for an endpoint-id call, with `{placeholder}` path params filled from
the caller's query params (they are consumed — dropped from the relayed query). Missing
required params fail HERE, before a credential is touched or money spent."""
path, consumed = ep["path"] or "/", set()
path = (ep.get("authorization_paths") or {}).get(authorization_method) or ep["path"] or "/"
inp = ep.get("input") or {}
for where in ("pathParams", "queryParams"):
for name, spec in (inp.get(where) or {}).items():
allowed = tuple((spec or {}).get("authorization_methods") or ())
if (authorization_method and allowed and authorization_method not in allowed
and query_params.get(name) is not None):
raise ResolutionFailed(
"catalog_parameter_invalid", status_code=400, detail=(
f"{ep['id']} does not accept {name} with {authorization_method}; "
f"choose {', '.join(allowed)} or remove {name}"))
consumed = set()
for name in re.findall(r"{(\w+)}", path):
value = query_params.get(name)
if value is None:
@@ -733,9 +776,11 @@ def _marketplace_upstream(ep: dict, provider, query_params) -> tuple[str, set[st
rendered = value if _VALID_PERCENT_ESCAPE_RE.search(value) else quote(value, safe="")
path = path.replace("{%s}" % name, rendered)
consumed.add(name)
inp = ep.get("input") or {}
required = [k for k, v in (inp.get("queryParams") or {}).items()
if isinstance(v, dict) and v.get("required") and query_params.get(k) is None]
if isinstance(v, dict) and v.get("required")
and (not v.get("authorization_methods")
or authorization_method in v["authorization_methods"])
and query_params.get(k) is None]
if required:
raise ResolutionFailed(
"catalog_parameter_invalid", status_code=400, detail=(
@@ -779,21 +824,119 @@ async def _enforce_capability_pin(ep: dict, caller: Caller, db: AsyncSession) ->
})
async def _provider_tool_grant(
service: str, methods: tuple[str, ...], caller: Caller, db: AsyncSession,
) -> tuple[Tool, Secret, str] | None:
"""Resolve a named catalog endpoint by provider and grant identity, not only by host.
This is the generic fix for providers that share one upstream host. It stays in catalog-call
resolution; the faithful relay still receives one resolved tool and knows no provider rules.
"""
tools = (await db.execute(select(Tool).where(Tool.org_id == caller.org_id))).scalars().all()
secrets = (await db.execute(select(Secret).where(
Secret.org_id == caller.org_id, Secret.provider == service,
))).scalars().all()
secrets_by_id = {secret.id: secret for secret in secrets}
provider = oauth_providers.get(service)
connection_names = {
item.name: item.connection_name for item in (provider.authorization_methods if provider else ())
}
matches: list[tuple[int, bool, int, Tool, Secret, str]] = []
denied = False
for tool in tools:
for binding in tool.bindings or []:
sid = binding.get("secret_id")
if sid is None:
continue
secret = secrets_by_id.get(sid)
if secret is None:
continue
method = _authorization_method(secret)
if method not in methods:
continue
if not access_policy._tool_usable(caller, tool):
denied = True
continue
priority = methods.index(method)
exact = tool.name == connection_names.get(method, service)
matches.append((priority, not exact, -(secret.id or 0), tool, secret, method))
if not matches:
if denied:
raise ResolutionFailed(
"tool_access_denied", status_code=403,
detail=f"a {service} authorization exists, but you do not have access to its tool",
)
return None
matches.sort(key=lambda item: item[:3])
_, _, _, tool, secret, method = matches[0]
return tool, secret, method
def _authorization_error(
ep: dict, method: str, *, code: str, explanation: str, scopes: list[str], authorization=None,
) -> ResolutionFailed:
capability = (
authorization.connect_capability if authorization else ep.get("authorization_capability")
)
command = f"treg connections connect --provider {ep['provider']}"
if capability:
command += f" --capability {capability}"
return ResolutionFailed("authorization_required", status_code=428, detail={
"error": code,
"provider": ep["provider"],
"endpoint_id": ep["id"],
"required_authorization_method": method,
"required_capability": capability,
"required_scopes": scopes,
"message": explanation,
"cli_command": command,
"dashboard_action": {
"label": authorization.action_label if authorization else "Add account",
"url": "/app#connections",
},
})
def _preflight_authorization(ep: dict, secret: Secret, method: str, authorization=None) -> None:
required = connection_authorization.required_scopes(ep, authorization)
state = expiry_state(secret.expires_at, oauth.secret_is_refreshable(secret))
if state == "expired":
raise _authorization_error(
ep, method, code="authorization_expired",
explanation=f"The {method} authorization has expired. A human must authorize it again.",
scopes=required, authorization=authorization,
)
missing = [scope for scope in required if scope not in secret.granted_scopes.split()]
if missing:
raise _authorization_error(
ep, method, code="authorization_scope_required",
explanation="The connected authorization does not include every permission that this tool requires.",
scopes=missing, authorization=authorization,
)
if ep.get("required_resource") and not secret.resource_ref:
raise _authorization_error(
ep, method, code="authorization_resource_required",
explanation=(
authorization.missing_message
if authorization and authorization.missing_message else
"No usable account is selected for this authorization."
),
scopes=required, authorization=authorization,
)
async def _resolve_marketplace_call(
ep: dict, *, method: str, query: QueryValues, has_body: bool,
read_body: Callable[[], Awaitable[bytes]], caller: Caller, db: AsyncSession,
resolve_call: Callable[[str, Caller, AsyncSession], Awaitable[ResolvedTarget]],
authorization_method: str = "",
) -> MarketplaceCall:
"""Walk the credential ladder for a catalog endpoint id → a `MarketplaceCall`.
"""Resolve a catalog call, selecting an explicit OAuth grant before host matching.
The tool is either the org's own registered tool for that provider (tier 1 — passthrough
resolution, so ACL filtering and the provider-owned tiebreak apply unchanged) or a virtual,
never-persisted Tool named after the ENDPOINT (tiers 2 and 4) — so the audit trail records the
endpoint id, and a member's restricted tool list can never contain it (governance: restricted
members get no direct marketplace calls; `_require_tool_use` enforces that downstream).
NOTHING is reserved here. Resolution only PRICES the call; `call_tool` reserves after the deny
rules and caps have had their say, so a refused call never has to un-hold money."""
Endpoints without authorization metadata retain the normal tool → credential → platform
ladder. Annotated endpoints select by provider plus grant method. That generic identity avoids
ambiguous same-host tools without teaching the faithful relay about Instagram or Meta.
"""
await _enforce_capability_pin(ep, caller, db)
_enforce_catalog_status(ep)
service = ep["provider"]
@@ -806,39 +949,74 @@ async def _resolve_marketplace_call(
raise ResolutionFailed(
"method_mismatch", status_code=400,
detail=f"{ep['id']} is {ep['method']} — add --method {ep['method']}")
upstream, consumed = _marketplace_upstream(ep, provider, query)
# The telemetry identity of this call, computed once. The body is read here (Starlette caches it,
# so the relay still streams the same bytes) only for its HASH — never stored, never logged.
try:
methods = connection_authorization.select_endpoint_methods(
ep, authorization_method,
)
except ValueError as exc:
raise ResolutionFailed(
"catalog_parameter_invalid", status_code=400, detail=str(exc),
) from None
chosen_tool: Tool | None = None
chosen_secret: Secret | None = None
chosen_method = ""
if methods:
authorization = None
grant = await _provider_tool_grant(service, methods, caller, db)
if grant is not None:
chosen_tool, chosen_secret, chosen_method = grant
else:
chosen_secret = await _marketplace_secret(service, caller.org_id, db, methods)
if chosen_secret is not None:
chosen_method = _authorization_method(chosen_secret)
if chosen_secret is None:
required_method = methods[0]
authorization = connection_authorization.method_spec(provider, required_method)
raise _authorization_error(
ep, required_method, code="authorization_missing",
explanation=(
authorization.missing_message if authorization and authorization.missing_message
else f"This tool requires {provider.display_name} authorization."
),
scopes=connection_authorization.required_scopes(ep, authorization),
authorization=authorization,
)
authorization = connection_authorization.method_spec(provider, chosen_method)
_preflight_authorization(ep, chosen_secret, chosen_method, authorization)
provider = provider.profile_for_authorization(chosen_method)
upstream, consumed = _marketplace_upstream(ep, provider, query, chosen_method)
body = await read_body() if has_body else b""
phash = _params_hash(ep["id"], query.multi_items(), body)
# The catalog's estimate travels on EVERY tier — informational on tiers 1/2 (the provider bills
# the org's own account; Activity shows "estimated") and the reserve amount on tier 4 only
# (`metered` gates the ledger, so this never charges a balance for an own-key call).
cv = catalog_store.load().cost_view(ep.get("cost"), service) if ep.get("cost") else None
info_est, info_unit = _marketplace_pricing(
service, ep["id"], cv, query, body)
common = dict(upstream=upstream, consumed=consumed, endpoint_id=ep["id"], provider=service,
params_hash=phash, cost_type=str((ep.get("cost") or {}).get("type") or ""),
estimate_micro=info_est,
# The per-ROW price, carried on every tier (settle only reads it on metered calls):
# a `per_result` settle that can't count rows can only ever bill the estimate,
# which is how 6,000 delivered Bright Data records once billed as one (2026-08-24).
unit_micro=info_unit)
try: # tier 1 — the org registered this provider: their tool, their bindings, their ACLs
target = await resolve_call(upstream, caller, db)
return MarketplaceCall(
tool=target.tool, tier="tool", **{**common, "upstream": target.upstream})
except ResolutionFailed as exc:
if exc.status_code != 404: # 403 (ACL) / 409 (ambiguous) are real answers, not fall-through
raise
secret = await _marketplace_secret(service, caller.org_id, db) # tier 2 — credential, no tool
info_est, info_unit = _marketplace_pricing(service, ep["id"], cv, query, body)
common = dict(
upstream=upstream, consumed=consumed, endpoint_id=ep["id"], provider=service,
params_hash=phash, cost_type=str((ep.get("cost") or {}).get("type") or ""),
estimate_micro=info_est, unit_micro=info_unit,
)
if chosen_tool is not None:
return MarketplaceCall(tool=chosen_tool, tier="tool", **common)
if not methods:
try:
target = await resolve_call(upstream, caller, db)
return MarketplaceCall(
tool=target.tool, tier="tool", **{**common, "upstream": target.upstream})
except ResolutionFailed as exc:
if exc.status_code != 404:
raise
secret = chosen_secret or await _marketplace_secret(service, caller.org_id, db)
if secret is not None:
virtual = Tool( # NEVER added to the session — no registry pollution, by design
virtual = Tool(
org_id=caller.org_id, name=ep["id"], owner=secret.owner,
base_url=provider.base_url, host=_host_of(provider.base_url),
bindings=_provider_bindings(provider, secret),
)
return MarketplaceCall(tool=virtual, tier="credential", **common)
# tier 4 — treg's own key, metered against the org's balance. Shadowed by tiers 1 and 2 above:
# an org that brought its own credential is billed by the provider, not by us, and must never be
# silently switched onto our key (their quota, their rate limits, their data agreements).
@@ -892,6 +1070,7 @@ async def resolve_marketplace_target(
read_body: Callable[[], Awaitable[bytes]],
caller: Caller,
resolve_call: Callable[[str, Caller, AsyncSession], Awaitable[ResolvedTarget]],
authorization_method: str = "",
) -> MarketplaceCall:
# The exhausted view is refreshed here — before the resolution session opens, so at most one
# connection is held at a time, and before any hold exists. Cached 60 s; a stale or empty view
@@ -909,6 +1088,7 @@ async def resolve_marketplace_target(
caller=caller,
db=db,
resolve_call=resolve_call,
authorization_method=authorization_method,
)
+2
View File
@@ -38,6 +38,7 @@ from .idempotency import IDEMPOTENCY_HEADER, _store_idempotent
from .intake import META_HEADER, _parse_call_meta, _tag_telemetry, prepare_call_intake
from .reserve import _enforce_tag_budgets, _platform_reserve
from .resolve import (
AUTHORIZATION_METHOD_HEADER,
MarketplaceCall,
QueryValues,
_billed_marketplace,
@@ -466,6 +467,7 @@ async def _execute_call(request: _ApplicationRequest, upstream_client: httpx.Asy
read_body=request.body,
caller=caller,
resolve_call=_resolve_call,
authorization_method=request.headers.get(AUTHORIZATION_METHOD_HEADER, ""),
), request, call_ref)
except CallFailure as mkexc:
# Catalog resolution is allowed to fall through from a named miss, but its own 404 must
+1
View File
@@ -53,6 +53,7 @@ _BLAME_BY_KIND: dict[str, Blame] = {
"stream_interrupted": "upstream",
"refresh_failed": "org_connection",
"credential_missing": "org_connection",
"authorization_required": "org_connection",
"method_mismatch": "caller",
}
+254 -148
View File
@@ -12,10 +12,14 @@ from sqlalchemy import or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from .. import crypto, health, oauth, oauth_providers
from .. import crypto, health, oauth_providers
from ..config import get_settings
from ..domain.catalog import store as catalog_store
from ..domain.connections import refresh as connection_refresh
from ..domain.connections.oauth_flow import consent_url
from ..infra.db import session_maker
from ..infra.oauth_exchange import HTTPXOAuthExchangePort
from ..infra.oauth_refresh import HTTPXOAuthRefreshPort
from ..models import PendingOAuth, Secret, Tool
from ..timeutil import as_naive as _as_naive
from ..timeutil import utcnow_naive as _utcnow_naive
@@ -226,8 +230,8 @@ def _provider_tool_examples(provider) -> list[dict]:
return out
async def _record_connected_identity(provider, secret: Secret, blob: dict, client) -> None:
"""Ask the provider who just connected, and remember it.
async def _record_connected_identity(provider, blob: dict, client) -> tuple[str, str] | None:
"""Ask the provider who just connected and return its stable id and label.
Providers with nothing to choose between (LinkedIn acts as the one member who consented) would
otherwise show a connection with no indication of WHICH account it is. This also captures the
@@ -239,16 +243,17 @@ async def _record_connected_identity(provider, secret: Secret, blob: dict, clien
headers={"Authorization": f"Bearer {blob.get('access_token')}"},
)
if resp.status_code != 200:
return
return None
data = resp.json()
ident = _dig(data, provider.identity_id_path)
if not ident:
return
secret.resource_ref = provider.identity_ref_format.format(id=ident)
return None
resource_ref = provider.identity_ref_format.format(id=ident)
label = _dig(data, provider.identity_label_path) if provider.identity_label_path else None
secret.resource_name = str(label) if label else str(ident)
return resource_ref, str(label) if label else str(ident)
except Exception as exc: # noqa: BLE001
print(f"[oauth] identity lookup failed for {provider.service}: {exc}")
return None
def _dig(obj, dotted: str):
@@ -291,7 +296,8 @@ async def start_oauth_connection(
async with session_maker() as db:
code_verifier, auth_params, auth_method = "", "", "client_secret_post"
cid_param, scope_sep = "client_id", " "
long_lived = False
long_lived, long_lived_style, authorization_method = False, "", ""
connect_guidance = ""
if provider_name:
provider = oauth_providers.get(provider_name)
@@ -304,17 +310,22 @@ async def start_oauth_connection(
chosen_capability = capability or provider.default_capability
try:
scopes = provider.scopes_for(chosen_capability)
client_id, client_secret = oauth_providers.credentials(provider)
authorization = provider.authorization_for_capability(chosen_capability)
profile = provider.profile_for_authorization(authorization.name if authorization else "")
client_id, client_secret = oauth_providers.credentials(profile)
except ValueError as exc:
raise ConnectError("invalid_provider", str(exc)) from None
auth_uri, token_uri = provider.auth_uri, provider.token_uri
name = name or provider.service
auth_method = provider.token_endpoint_auth_method
cid_param, scope_sep = provider.client_id_param, provider.scope_separator
long_lived = provider.long_lived_exchange
if provider.auth_params is not None:
auth_params = json.dumps(provider.auth_params)
if provider.pkce:
authorization_method = authorization.name if authorization else ""
connect_guidance = authorization.description if authorization else ""
auth_uri, token_uri = profile.auth_uri, profile.token_uri
name = name or (authorization.connection_name if authorization else provider.service)
auth_method = profile.token_endpoint_auth_method
cid_param, scope_sep = profile.client_id_param, profile.scope_separator
long_lived = profile.long_lived_exchange
long_lived_style = profile.long_lived_exchange_style
if profile.auth_params is not None:
auth_params = json.dumps(profile.auth_params)
if profile.pkce:
code_verifier = crypto.new_token()
elif not (client_id and client_secret):
raise ConnectError(
@@ -339,6 +350,17 @@ async def start_oauth_connection(
"invalid_provider",
f"connection {connection_id} is {target.provider or 'not a provider connection'}, not {provider_name}",
)
target_provider = oauth_providers.get(target.provider) if target.provider else None
target_method = (
target_provider.authorization_method_name(target.authorization_method)
if target_provider else target.authorization_method
)
if provider_name and target_method != authorization_method:
raise ConnectError(
"invalid_provider",
f"connection {connection_id} uses {target_method or 'the default'} authorization, "
f"not {authorization_method or 'the default'} authorization",
)
replaces_id = target.id
name = target.name
@@ -354,21 +376,29 @@ async def start_oauth_connection(
client_id=client_id, client_secret=crypto.encrypt(client_secret),
auth_uri=auth_uri, token_uri=token_uri, scopes=scope_sep.join(scopes),
redirect_uri=redirect_uri, provider=provider_name or "",
authorization_method=authorization_method if provider_name else "",
code_verifier=code_verifier, auth_params=auth_params,
token_endpoint_auth_method=auth_method, client_id_param=cid_param,
scope_separator=scope_sep, long_lived_exchange=long_lived,
long_lived_exchange_style=long_lived_style if provider_name else "",
replaces_secret_id=replaces_id,
)
db.add(pending)
await db.commit()
return {"state": state, "consent_url": oauth.consent_url(pending), "redirect_uri": redirect_uri}
return {
"state": state,
"consent_url": consent_url(pending),
"redirect_uri": redirect_uri,
"connect_guidance": connect_guidance,
}
async def complete_oauth_connection(
*, state: str, code: str, error: str, client_factory,
) -> OAuthCallbackOutcome:
# Phase 1 reads and validates the pending grant. Provider requests happen only after this
# session closes; a slow OAuth or identity endpoint must not hold a pool slot or transaction.
async with session_maker() as db:
# Hit by the BROWSER on redirect — no token; protected by the unguessable `state`.
pending = (
await db.execute(select(PendingOAuth).where(PendingOAuth.state == state))
).scalar_one_or_none()
@@ -387,10 +417,40 @@ async def complete_oauth_connection(
await db.commit()
return OAuthCallbackOutcome("authorization_failed")
try:
client = client_factory()
blob = await HTTPXOAuthExchangePort(client).exchange_code(pending, code)
provider = oauth_providers.get(pending.provider) if pending.provider else None
profile = (
provider.profile_for_authorization(pending.authorization_method)
if provider else None
)
identity = (
await _record_connected_identity(profile, blob, client)
if profile and profile.has_identity else None
)
except Exception as exc: # noqa: BLE001
print(f"[oauth] token exchange failed for state {state}: {exc}")
async with session_maker() as db:
current = (
await db.execute(select(PendingOAuth).where(PendingOAuth.state == state))
).scalar_one_or_none()
if current is not None and current.status == "pending":
current.status, current.detail = "error", "token exchange failed"
await db.commit()
return OAuthCallbackOutcome("exchange_failed")
# Phase 2 stores the result and provisions local rows. Re-read the pending row so two callback
# deliveries cannot create two credentials after both complete their provider requests.
async with session_maker() as db:
pending = (
await db.execute(select(PendingOAuth).where(PendingOAuth.state == state))
).scalar_one_or_none()
if pending is None:
return OAuthCallbackOutcome("invalid")
if pending.status != "pending":
return OAuthCallbackOutcome("done" if pending.status == "done" else "already_failed")
try:
client = client_factory()
blob = await oauth.exchange_code(pending, code, client)
provider = oauth_providers.get(pending.provider) if pending.provider else None
# A consent either REPLACES one named connection or ADDS another. `replaces_secret_id` says
# which, decided back at /oauth/start where the user's intent was known. This used to
# blanket-replace by provider, which fixed the real bug — widening read→write silently made
@@ -413,29 +473,40 @@ async def complete_oauth_connection(
secret.value = crypto.encrypt(json.dumps(blob))
secret.last_error = ""
secret.provider = pending.provider or ""
secret.authorization_method = pending.authorization_method or ""
# granted_scopes stays canonically SPACE-joined whatever dialect went over the wire, so the
# readers (satisfied_capabilities, the health payload) can keep using a plain .split().
# TikTok comma-joins its consent scopes; without this normalisation a whole grant would
# come back as one bogus scope string and every capability would read as unsatisfied.
separator = pending.scope_separator or " "
secret.granted_scopes = " ".join(s for s in pending.scopes.split(separator) if s)
secret.expires_at = oauth.expiry_of(blob)
secret.expires_at = connection_refresh.expiry_of(blob)
await db.flush()
# A connect that yields no callable tool is a dead end — the user consented and got
# nothing. Auto-provision the provider's tool bound to this credential so the very next
# thing they can do is make a real proxied call.
if provider and provider.can_autoprovision:
await _autoprovision_provider_tool(provider, secret, pending, db)
if provider and provider.has_identity:
await _record_connected_identity(provider, secret, blob, client)
if identity is not None:
secret.resource_ref, secret.resource_name = identity
if profile and profile.identity_required and identity is None:
secret.health_status = "setup_required"
secret.health_detail = (
profile.identity_missing_detail
or "The provider did not return a usable account. Confirm the account setup, then connect again."
)
elif profile and profile.can_autoprovision:
await _autoprovision_provider_tool(profile, secret, pending, db)
if identity is not None:
secret.health_status = "ok"
secret.health_detail = "The provider returned a usable account."
secret.health_checked_at = _utcnow_naive()
pending.status, pending.secret_id, pending.detail = "done", secret.id, "connected"
await db.commit()
except Exception as exc: # noqa: BLE001
print(f"[oauth] token exchange failed for state {state}: {exc}") # detail stays server-side
pending.status, pending.detail = "error", "token exchange failed"
print(f"[oauth] connection storage failed for state {state}: {exc}")
pending.status, pending.detail = "error", "connection storage failed"
await db.commit()
return OAuthCallbackOutcome("exchange_failed")
return OAuthCallbackOutcome("connected")
return OAuthCallbackOutcome("connected")
async def connect_with_pasted_secret(
@@ -567,7 +638,7 @@ async def connect_with_pasted_secret(
await _autoprovision_provider_tool(provider, secret, pending, db)
await db.commit()
await db.refresh(secret)
return oauth.connection_view(secret)
return connection_refresh.connection_view(secret)
async def get_oauth_status(*, state: str, org_id: int) -> dict:
@@ -641,22 +712,32 @@ async def list_connections(*, org_id: int) -> list[dict]:
).scalars().all()
out = []
for s in rows:
view = oauth.connection_view(s)
view = connection_refresh.connection_view(s)
provider = oauth_providers.get(s.provider) if s.provider else None
if provider is not None:
method_name = provider.authorization_method_name(s.authorization_method)
profile = provider.profile_for_authorization(method_name)
granted = s.granted_scopes.split()
have = provider.satisfied_capabilities(granted)
view["capabilities"] = have
# Providers don't backfill scopes onto an issued grant, so a capability the user never
# consented to can only be added by re-consenting. Naming the gap here is what turns an
# opaque upstream 403 into "reconnect to enable write".
view["missing_capabilities"] = [c for c in provider.capabilities if c not in have]
if not provider.extra_credential_is_platform:
view["extra_credential_note"] = provider.extra_credential_note
view["extra_credential_label"] = provider.extra_credential_label
view["authorization_method"] = method_name or "default"
method = next(
(m for m in provider.authorization_methods if m.name == method_name), None
)
view["authorization_method_label"] = method.display_name if method else "Connected"
view["supports_discovery"] = profile.supports_discovery
view["resource_label"] = profile.resource_label
eligible = method.capabilities if method else tuple(provider.capabilities)
view["missing_capabilities"] = [c for c in eligible if c not in have]
if not profile.extra_credential_is_platform:
view["extra_credential_note"] = profile.extra_credential_note
view["extra_credential_label"] = profile.extra_credential_label
# Outstanding only while no tool exists for this provider — once one does, the second
# credential has been supplied and the connection is callable.
if provider.needs_extra_credential and not provider.extra_credential_is_platform:
if profile.needs_extra_credential and not profile.extra_credential_is_platform:
built = (await db.execute(
select(Tool).where(Tool.org_id == org_id, Tool.name == provider.service)
)).scalars().first()
@@ -666,113 +747,131 @@ async def list_connections(*, org_id: int) -> list[dict]:
return out
async def _connection_for_upstream(
*, secret_id: int, org_id: int, client,
) -> tuple[Secret, str]:
"""Load one connection and delegate freshness to the single domain implementation."""
async with session_maker() as db:
secret = await _owned_connection(secret_id, org_id, db)
await connection_refresh.ensure_fresh(
secret, db, HTTPXOAuthRefreshPort(client),
)
return secret, crypto.decrypt(secret.value)
async def list_connection_resources(
*, secret_id: int, org_id: int, client_factory,
) -> dict:
async with session_maker() as db:
secret = await _owned_connection(secret_id, org_id, db)
provider = oauth_providers.get(secret.provider)
if provider is None or not provider.supports_discovery:
raise ConnectError(
"no_resource_discovery",
f"{secret.provider or 'this provider'} has nothing to choose between — it acts on your whole account",
)
client = client_factory()
await oauth.ensure_fresh(secret, db, client) # no-op for a non-oauth secret
# A pasted-secret (bot token / API key) secret is a PLAIN STRING, not an oauth blob — json.loads
# on it throws. (Only header-auth pasted providers reach here; a query-key provider like Semrush
# has nothing to discover, so supports_discovery is False and this endpoint 422s earlier.)
raw = crypto.decrypt(secret.value)
if provider.uses_pasted_secret:
disc_headers = {provider.token_header: provider.token_format.format(secret=raw)}
else:
blob = json.loads(raw)
token = blob.get("access_token") or blob.get("token")
disc_headers = {"Authorization": f"Bearer {token}"}
if provider.needs_extra_credential: # Ads won't list accounts without the developer token
disc_headers[provider.extra_credential_header] = provider.platform_extra_credential
resp = await client.get(
f"{provider.discovery_base.rstrip('/')}{provider.discover_path}",
headers=disc_headers,
client = client_factory()
secret, raw = await _connection_for_upstream(
secret_id=secret_id, org_id=org_id, client=client,
)
provider = oauth_providers.get(secret.provider)
if provider is not None:
provider = provider.profile_for_authorization(
provider.authorization_method_name(secret.authorization_method)
)
if provider is None or not provider.supports_discovery:
raise ConnectError(
"no_resource_discovery",
f"{secret.provider or 'this provider'} has nothing to choose between — it acts on your whole account",
)
if provider.uses_pasted_secret:
token = raw
disc_headers = {provider.token_header: provider.token_format.format(secret=raw)}
else:
blob = json.loads(raw)
token = blob.get("access_token") or blob.get("token")
disc_headers = {"Authorization": f"Bearer {token}"}
if provider.needs_extra_credential:
disc_headers[provider.extra_credential_header] = provider.platform_extra_credential
# All provider I/O is outside a database session, including optional enrichment.
resp = await client.get(
f"{provider.discovery_base.rstrip('/')}{provider.discover_path}", headers=disc_headers,
)
try:
body = resp.json()
except Exception: # noqa: BLE001
body = {}
try:
body = resp.json()
except Exception: # noqa: BLE001
body = {}
# Slack answers 200 with {"ok": false, "error": "missing_scope"} — status alone would report an
# empty picker instead of naming the scope the bot is missing.
if resp.status_code >= 400 or body.get("ok") is False:
upstream = ""
err = body.get("error")
if isinstance(err, dict):
upstream = err.get("message", "")
elif isinstance(err, str):
upstream = err
if not upstream:
upstream = (resp.text or "")[:200]
raise ConnectError(
"resource_discovery_failed",
f"could not list {provider.resource_plural} ({resp.status_code}): {upstream}".strip(),
)
# A successful discovery call is a real authenticated request to the upstream — the strongest
# evidence we get that this credential works. Recording it turns the connection's health from
# "unknown" into something earned, instead of waiting for the next health sweep.
if secret.health_status != "ok":
secret.health_status, secret.health_detail = "ok", "listed upstream resources"
secret.health_checked_at = _utcnow_naive()
await db.commit()
rows = body.get(provider.discover_key) or []
if provider.discover_nested_key: # e.g. GA4 properties nested inside each account summary
rows = [n for r in rows if isinstance(r, dict) for n in (r.get(provider.discover_nested_key) or [])]
# Some providers expose delegated assets through a second listing whose rows hold nested
# lists of primary-shaped resources. Best-effort by design: an older grant can lack the
# extra scope while the primary result remains valid.
if provider.discover_extra_path:
try:
extra = await client.get(
f"{provider.discovery_base.rstrip('/')}{provider.discover_extra_path}",
headers=disc_headers,
)
if extra.status_code < 400:
for holder in (extra.json().get(provider.discover_key) or []):
for path in provider.discover_extra_list_paths:
rows.extend(n for n in (_dig(holder, path) or []) if isinstance(n, dict))
except Exception: # noqa: BLE001 — the extra listing must never break the picker
pass
label_field = provider.discover_label_field or provider.discover_id_field
resources = [
# A row is usually an object, but some providers return bare strings — Google Ads'
# listAccessibleCustomers gives ["customers/6186675831", …]. Treat the string as both id
# and label rather than silently dropping every row.
{"id": r, "label": r.rsplit("/", 1)[-1], "raw": r} if isinstance(r, str)
# _dig, not .get — YouTube's channel title is nested at snippet.title. A plain key is just
# a one-hop path, so every existing provider walks the same code.
else {"id": _dig(r, provider.discover_id_field), "label": _dig(r, label_field), "raw": r}
for r in rows if isinstance(r, (dict, str))
if resp.status_code >= 400 or body.get("ok") is False:
err = body.get("error")
upstream = err.get("message", "") if isinstance(err, dict) else (err or "")
if not upstream:
upstream = (resp.text or "")[:200]
raise ConnectError(
"resource_discovery_failed",
f"could not list {provider.resource_plural} ({resp.status_code}): {upstream}".strip(),
)
rows = body.get(provider.discover_key) or []
if provider.discover_nested_key:
rows = [
nested for row in rows if isinstance(row, dict)
for nested in (row.get(provider.discover_nested_key) or [])
]
if provider.discover_extra_path:
# Direct and delegated listings can overlap. Keep the first primary sighting. Remove
# id-less rows too, or one None value can survive as a phantom picker choice.
seen: set = set()
resources = [x for x in resources if x["id"] and not (x["id"] in seen or seen.add(x["id"]))]
if provider.supports_enrichment:
await _enrich_resource_labels(provider, resources, token, client)
# Self-heal a connection whose target was chosen before we stored labels (or via the API, which
# has no label to give). We're already holding the upstream's own naming — resolving it here
# spares the user a pointless re-pick just to make the row readable.
if secret.resource_ref and not secret.resource_name:
match = next((x for x in resources if x["id"] == secret.resource_ref), None)
if match and match["label"]:
secret.resource_name = match["label"]
await db.commit()
return {
"provider": provider.service,
"resource_label": provider.resource_label,
"resource_plural": provider.resource_plural,
"selected": secret.resource_ref,
"resources": resources,
if provider.discover_extra_path:
try:
extra = await client.get(
f"{provider.discovery_base.rstrip('/')}{provider.discover_extra_path}",
headers=disc_headers,
)
if extra.status_code < 400:
for holder in (extra.json().get(provider.discover_key) or []):
for path in provider.discover_extra_list_paths:
rows.extend(
item for item in (_dig(holder, path) or []) if isinstance(item, dict)
)
except Exception: # noqa: BLE001
pass
label_field = provider.discover_label_field or provider.discover_id_field
resources = [
{"id": row, "label": row.rsplit("/", 1)[-1], "raw": row}
if isinstance(row, str) else {
"id": _dig(row, provider.discover_id_field),
"label": _dig(row, label_field), "raw": row,
}
for row in rows if isinstance(row, (dict, str))
]
if provider.discover_extra_path:
seen: set = set()
resources = [
item for item in resources
if item["id"] and not (item["id"] in seen or seen.add(item["id"]))
]
if provider.supports_enrichment:
await _enrich_resource_labels(provider, resources, token, client)
async with session_maker() as db:
current = await _owned_connection(secret_id, org_id, db)
if current.value != secret.value:
raise ConnectError(
"connection_changed",
"the connection changed during account discovery; list the accounts again",
)
setup_required = provider.discovery_required and not resources
if setup_required:
current.health_status = "setup_required"
current.health_detail = (
provider.empty_resource_detail or "No usable account was returned."
)
else:
current.health_status, current.health_detail = "ok", "listed upstream resources"
current.health_checked_at = _utcnow_naive()
if current.resource_ref and not current.resource_name:
match = next((item for item in resources if item["id"] == current.resource_ref), None)
if match and match["label"]:
current.resource_name = match["label"]
await db.commit()
selected = current.resource_ref
return {
"provider": provider.service,
"resource_label": provider.resource_label,
"resource_plural": provider.resource_plural,
"selected": selected,
"resources": resources,
"setup_required": setup_required,
"setup_detail": provider.empty_resource_detail if setup_required else "",
}
async def select_connection_resource(
@@ -781,15 +880,18 @@ async def select_connection_resource(
provider = None
source_value = ""
setup_fields: dict[str, str] | None = None
async with session_maker() as db:
secret = await _owned_connection(secret_id, org_id, db)
provider = oauth_providers.get(secret.provider) if secret.provider else None
if provider and provider.resource_token_path:
client = client_factory()
await oauth.ensure_fresh(secret, db, client)
await db.refresh(secret)
source_value = secret.value
granted_scopes = secret.granted_scopes
client = client_factory()
secret, _ = await _connection_for_upstream(
secret_id=secret_id, org_id=org_id, client=client,
)
provider = oauth_providers.get(secret.provider) if secret.provider else None
if provider is not None:
provider = provider.profile_for_authorization(
provider.authorization_method_name(secret.authorization_method)
)
if provider and provider.resource_token_path:
source_value = secret.value
granted_scopes = secret.granted_scopes
# Resource discovery and provider setup are external work. Do them after the read session is
# closed so provider latency cannot hold a database connection or transaction open.
@@ -836,7 +938,7 @@ async def select_connection_resource(
tool.examples = [rendered] + others
await db.commit()
await db.refresh(secret)
return oauth.connection_view(secret)
return connection_refresh.connection_view(secret)
async def _resolve_resource_call_token(
@@ -1037,7 +1139,11 @@ async def supply_extra_credential(
tool.bindings = bindings
await db.commit()
await db.refresh(secret)
return {**oauth.connection_view(secret), "tool": provider.service, "ready": True}
return {
**connection_refresh.connection_view(secret),
"tool": provider.service,
"ready": True,
}
async def revoke_connection(*, secret_id: int, org_id: int) -> dict:
+237 -11
View File
@@ -1,8 +1,7 @@
# instagram — EXTENDED tier: the provider's full endpoint surface, machine-generated by
# scripts/catalog_ingest.py. Do not hand-edit; re-run the script instead. Entries start with
# no capability, no verification and no example response — verification stamps and reviewed
# `capability` mappings (with their platform correction) are added later and carried across
# re-ingests by id via carry_verification. Routes curated in core instagram.yaml are excluded here.
# scripts/catalog_ingest.py. Reviewed request, authorization, capability and verification fields
# are carried across re-ingests by id via carry_verification. Routes curated in core
# instagram.yaml are excluded here.
#
# META PUBLISHES NO SPEC. There is no OpenAPI document, no discovery document and no
# machine-readable index for the Graph API — the only source is the HTML reference, one page
@@ -11,10 +10,11 @@
# can actually reach, read off the official reference page linked in every docs_url. Accuracy
# over volume — assume the Graph surface is much larger than what is listed here.
#
# Paths are relative to base_url, which ALREADY ENDS IN /v25.0 — no entry repeats the version.
# Bumping the Graph version is a base_url change in oauth_providers.py, not an edit here.
# Paths are relative to the method profile's base URL, which ends in /v25.0. Direct-supported
# rows use graph.instagram.com. Page-only rows use graph.facebook.com.
#
# treg's app requests instagram_basic, instagram_manage_insights, pages_show_list, pages_read_engagement (+ instagram_content_publish for `post`; instagram_manage_comments and instagram_manage_messages for `manage`).
# Direct grants request instagram_business_* scopes. The optional Page grant keeps the existing
# instagram_* and pages_* scopes.
# 2 entries need a scope beyond that and carry `scope_gap:`. Meta gates most of those behind
# App Review with a screencast per permission, so a scope gap here is a product decision, not
# a config change.
@@ -32,10 +32,19 @@ source:
- https://developers.facebook.com/docs/graph-api/reference
endpoints:
- id: instagram.x.hashtag-search
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /ig_hashtag_search
input:
queryParams:
user_id: {type: string, required: true, note: "Instagram Professional account id making the search", example: "17841400000000000"}
q: {type: string, required: true, note: "hashtag text without the # prefix", example: "coffee"}
summary: Resolve a hashtag name to its id — the first call of any hashtag lookup
scope: own_account
cost:
@@ -48,10 +57,18 @@ endpoints:
name: Resolve an Instagram hashtag name to its id
kind: utility
- id: instagram.x.comment-delete
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_comments]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: DELETE
path: /{ig_comment_id}
input:
pathParams:
ig_comment_id: {type: string, required: true, note: "comment id from the media comments edge", example: "17900000000000000"}
summary: Delete a comment on the account's own media
scope: own_account
cost:
@@ -64,10 +81,20 @@ endpoints:
name: Delete a comment on your Instagram media
kind: action
- id: instagram.x.comment
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_comments]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: GET
path: /{ig_comment_id}
input:
pathParams:
ig_comment_id: {type: string, required: true, note: "comment id from the media comments edge", example: "17900000000000000"}
queryParams:
fields: {type: string, required: false, note: "comma-separated comment fields", example: "id,text,username,timestamp,like_count"}
summary: A single comment — text, username, timestamp and like count
scope: own_account
cost:
@@ -79,10 +106,21 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: One Instagram comment — text, username, likes
- id: instagram.x.comment-replies
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_comments]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: GET
path: /{ig_comment_id}/replies
input:
pathParams:
ig_comment_id: {type: string, required: true, note: "parent comment id", example: "17900000000000000"}
queryParams:
fields: {type: string, required: false, note: "comma-separated reply fields", example: "id,text,username,timestamp"}
limit: {type: integer, required: false, example: 25}
summary: The replies under a comment
scope: own_account
cost:
@@ -94,10 +132,20 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Replies under an Instagram comment
- id: instagram.x.comment-reply-create
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_comments]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: POST
path: /{ig_comment_id}/replies
input:
pathParams:
ig_comment_id: {type: string, required: true, note: "comment id to reply under", example: "17900000000000000"}
queryParams:
message: {type: string, required: true, note: "reply text", example: "Thanks for your comment."}
summary: Reply to a comment as the account
scope: own_account
cost:
@@ -110,11 +158,21 @@ endpoints:
name: Reply to a comment as your Instagram account
kind: action
- id: instagram.x.comment-hide
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_comments]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: POST
path: /{ig_comment_id}?hide=true
summary: Hide a comment on the account's own media
path: /{ig_comment_id}
input:
pathParams:
ig_comment_id: {type: string, required: true, note: "comment id on the account's own media", example: "17900000000000000"}
queryParams:
hide: {type: boolean, required: true, note: "true hides the comment; false unhides it", example: true}
summary: Hide or unhide a comment on the account's own media
scope: own_account
cost:
type: free
@@ -123,13 +181,23 @@ endpoints:
unit: call
note: no per-call charge; counted against the app's Graph API rate limit
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Hide a comment on your Instagram media
name: Hide or unhide a comment on your Instagram media
kind: action
- id: instagram.x.hashtag
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_hashtag_id}
input:
pathParams:
ig_hashtag_id: {type: string, required: true, note: "id returned by instagram.x.hashtag-search", example: "17843800000000000"}
queryParams:
fields: {type: string, required: false, example: "id,name"}
summary: A hashtag's id and name
scope: own_account
cost:
@@ -141,10 +209,22 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: An Instagram hashtag's id and name
- id: instagram.x.hashtag-recent-media
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_hashtag_id}/recent_media
input:
pathParams:
ig_hashtag_id: {type: string, required: true, note: "id returned by instagram.x.hashtag-search", example: "17843800000000000"}
queryParams:
user_id: {type: string, required: true, note: "Instagram Professional account id making the lookup", example: "17841400000000000"}
fields: {type: string, required: false, example: "id,caption,media_type,permalink,timestamp"}
limit: {type: integer, required: false, example: 25}
summary: The most recent public posts carrying a hashtag
scope: own_account
cost:
@@ -156,10 +236,22 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Recent public Instagram posts for a hashtag id
- id: instagram.x.hashtag-top-media
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_hashtag_id}/top_media
input:
pathParams:
ig_hashtag_id: {type: string, required: true, note: "id returned by instagram.x.hashtag-search", example: "17843800000000000"}
queryParams:
user_id: {type: string, required: true, note: "Instagram Professional account id making the lookup", example: "17841400000000000"}
fields: {type: string, required: false, example: "id,caption,media_type,permalink,timestamp"}
limit: {type: integer, required: false, example: 25}
summary: The most popular public posts carrying a hashtag
scope: own_account
cost:
@@ -171,10 +263,20 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Top public Instagram posts for a hashtag id
- id: instagram.x.media
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: GET
path: /{ig_media_id}
input:
pathParams:
ig_media_id: {type: string, required: true, note: "media id from instagram.instagram.user.posts", example: "17900000000000000"}
queryParams:
fields: {type: string, required: false, note: "comma-separated media fields", example: "id,caption,media_type,media_url,permalink,timestamp,like_count,comments_count"}
summary: A single post — caption, media_type, media_url, permalink, timestamp, like and comment counts
scope: own_account
cost:
@@ -186,10 +288,21 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: One of your Instagram posts — caption, media URL, counts
- id: instagram.x.media-children
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: GET
path: /{ig_media_id}/children
input:
pathParams:
ig_media_id: {type: string, required: true, note: "carousel album media id", example: "17900000000000000"}
queryParams:
fields: {type: string, required: false, example: "id,media_type,media_url,permalink,timestamp"}
limit: {type: integer, required: false, example: 25}
summary: The individual images or videos inside a carousel post
scope: own_account
cost:
@@ -201,10 +314,20 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Images and videos inside an Instagram carousel post
- id: instagram.x.media-comment-create
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_comments]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: POST
path: /{ig_media_id}/comments
input:
pathParams:
ig_media_id: {type: string, required: true, note: "one of the connected account's media ids", example: "17900000000000000"}
queryParams:
message: {type: string, required: true, note: "comment text", example: "Posted through treg."}
summary: Comment on the account's own media
scope: own_account
cost:
@@ -217,10 +340,22 @@ endpoints:
name: Comment on your own Instagram media
kind: action
- id: instagram.x.catalog-product-search
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, instagram_shopping_tag_products, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/catalog_product_search
input:
pathParams:
ig_user_id: {type: string, required: true, note: "Page-linked Instagram Business account id discovered by Facebook Page tools; not the Facebook Page id or Instagram Login /me user_id", example: "17841400000000000"}
queryParams:
catalog_id: {type: string, required: true, note: "Meta Commerce catalog id linked to the Instagram shop", example: "123456789012345"}
q: {type: string, required: true, note: "product name or keyword to search", example: "shirt"}
fields: {type: string, required: false, note: "comma-separated product fields"}
summary: Search the linked commerce catalog for products to tag in a post
scope: own_account
cost:
@@ -233,10 +368,20 @@ endpoints:
scope_gap: treg's OAuth app does not request this; needs instagram_shopping_tag_products
name: Search your commerce catalog for products to tag
- id: instagram.x.user-content-publishing-limit
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_content_publish]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/content_publishing_limit
input:
pathParams:
ig_user_id: {type: string, required: true, note: "connected Instagram Professional account id", example: "17841400000000000"}
queryParams:
fields: {type: string, required: false, example: "quota_usage,config"}
summary: How many of the 24-hour posting quota (50 posts) this account has already used
scope: own_account
cost:
@@ -249,10 +394,20 @@ endpoints:
name: Your remaining Instagram daily posting quota
kind: utility
- id: instagram.x.user-live-media
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/live_media
input:
pathParams:
ig_user_id: {type: string, required: true, note: "connected Instagram Professional account id", example: "17841400000000000"}
queryParams:
fields: {type: string, required: false, example: "id,media_type,media_url,permalink,timestamp"}
summary: The account's currently running live broadcasts
scope: own_account
cost:
@@ -264,10 +419,21 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Your Instagram live broadcasts now running
- id: instagram.x.user-mentioned-comment
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/mentioned_comment
input:
pathParams:
ig_user_id: {type: string, required: true, note: "Page-linked Instagram Business account id discovered by Facebook Page tools; not the Facebook Page id or Instagram Login /me user_id", example: "17841400000000000"}
queryParams:
comment_id: {type: string, required: true, note: "id of the comment that mentions the account", example: "17900000000000000"}
fields: {type: string, required: false, example: "id,text,username,timestamp"}
summary: A comment that @-mentions this account, with its thread
scope: own_account
cost:
@@ -279,10 +445,21 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: A comment that @-mentions your Instagram account
- id: instagram.x.user-mentioned-media
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/mentioned_media
input:
pathParams:
ig_user_id: {type: string, required: true, note: "Page-linked Instagram Business account id discovered by Facebook Page tools; not the Facebook Page id or Instagram Login /me user_id", example: "17841400000000000"}
queryParams:
media_id: {type: string, required: true, note: "id of the media whose caption mentions the account", example: "17900000000000000"}
fields: {type: string, required: false, example: "id,caption,media_type,permalink,timestamp"}
summary: Media whose caption @-mentions this account
scope: own_account
cost:
@@ -294,10 +471,20 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Posts whose caption @-mentions your Instagram account
- id: instagram.x.user-product-appeal
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, instagram_shopping_tag_products, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/product_appeal
input:
pathParams:
ig_user_id: {type: string, required: true, note: "Page-linked Instagram Business account id discovered by Facebook Page tools; not the Facebook Page id or Instagram Login /me user_id", example: "17841400000000000"}
queryParams:
product_id: {type: string, required: true, note: "id of the rejected catalog product whose appeal status to read", example: "987654321012345"}
summary: The status of appeals against rejected shopping products
scope: own_account
cost:
@@ -310,10 +497,18 @@ endpoints:
scope_gap: treg's OAuth app does not request this; needs instagram_shopping_tag_products
name: Status of appeals for rejected shopping products
- id: instagram.x.user-recently-searched-hashtags
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/recently_searched_hashtags
input:
pathParams:
ig_user_id: {type: string, required: true, note: "linked Instagram Professional account id", example: "17841400000000000"}
summary: The hashtags this account has looked up recently (the 30-per-week search quota)
scope: own_account
cost:
@@ -325,10 +520,20 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Hashtags your Instagram account searched recently
- id: instagram.x.user-stories
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic]
required_resource: instagram_professional_account
token_type: instagram_user
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/stories
input:
pathParams:
ig_user_id: {type: string, required: true, note: "connected Instagram Professional account id", example: "17841400000000000"}
queryParams:
fields: {type: string, required: false, example: "id,caption,media_type,media_url,permalink,timestamp"}
summary: The account's stories from the last 24 hours
scope: own_account
cost:
@@ -340,10 +545,21 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Your Instagram stories from the last 24 hours
- id: instagram.x.user-tags
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}/tags
input:
pathParams:
ig_user_id: {type: string, required: true, note: "linked Instagram Professional account id", example: "17841400000000000"}
queryParams:
fields: {type: string, required: false, example: "id,caption,media_type,permalink,timestamp"}
limit: {type: integer, required: false, example: 25}
summary: Media where another account tagged this one in the photo
scope: own_account
cost:
@@ -355,10 +571,20 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference
name: Posts where your Instagram account is tagged
- id: instagram.x.user-business-discovery
authorization_method: facebook-page
authorization_methods: [facebook-page]
required_scopes: [instagram_basic, pages_read_engagement]
required_resource: linked_instagram_professional_account
token_type: facebook_page
tier: extended
platform: instagram
method: GET
path: /{ig_user_id}?fields=business_discovery.username({username})
path: /{ig_user_id}
input:
pathParams:
ig_user_id: {type: string, required: true, note: "your Page-linked Instagram Business account id discovered by Facebook Page tools; not the Facebook Page id or Instagram Login /me user_id", example: "17841400000000000"}
queryParams:
fields: {type: string, required: true, note: "business_discovery expression containing another public Business or Creator username without @ and the fields to return", example: "business_discovery.username(instagram){id,username,followers_count,media_count,media.limit(5){id,caption,media_type,permalink,timestamp}}"}
summary: Read ANOTHER public professional account's followers, media count and recent posts
scope: own_account
cost:
+70 -21
View File
@@ -1,22 +1,13 @@
# Live-verified 2026-07-28 through the treg /call proxy (connection: instagram). Every READ
# endpoint passes; the publishing routes below are marked separately and were not called.
#
# Instagram Graph API, served from the Meta host (graph.facebook.com/v25.0) with the access token
# of the Facebook Page linked to the selected account. It reaches only a PROFESSIONAL (Business or Creator) Instagram account that
# is linked to a Facebook Page the member administers — a personal Instagram account is invisible
# to this API entirely.
# The Facebook-backed routes were live-verified 2026-07-28 through treg. The 2026-09-01 audit adds
# official Instagram Login routing on graph.instagram.com for professional accounts that do not
# need a linked Page. Publishing and messaging writes still require deliberate manual verification.
#
# instagram.user.profile and instagram.user.posts are the same jobs tikhub.yaml scrapes
# any_account; the difference is that these return the account's own private metrics (reach,
# saves, profile views) that no scraper can see.
#
# Scopes requested by the broadest `manage` connection: instagram_basic,
# instagram_manage_insights, pages_show_list, pages_read_engagement, business_management,
# instagram_content_publish, instagram_manage_comments and instagram_manage_messages.
#
# The IG user id is NOT the @handle and not the Page id: read it from
# /me/accounts?fields=instagram_business_account{id,username} — the same call treg's connection
# picker uses.
# The default direct grant uses instagram_business_* scopes and an Instagram User token. The
# optional page-tools grant maps those capabilities to Facebook scopes and a selected Page token.
provider: instagram
source:
docs: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api
@@ -27,6 +18,11 @@ limits: "Graph API business-use-case limits"
pricing_url: https://developers.facebook.com/docs/instagram-api/
endpoints:
- id: instagram.instagram.user.profile
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic]
required_resource: instagram_professional_account
token_type: instagram_user
capability: instagram.user.profile
platform: instagram
scope: own_account
@@ -49,6 +45,11 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/reference/instagram-user
- id: instagram.instagram.user.posts
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic]
required_resource: instagram_professional_account
token_type: instagram_user
capability: instagram.user.posts
platform: instagram
scope: own_account
@@ -74,6 +75,11 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/reference/instagram-user/media
- id: instagram.instagram.post.comments
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_comments]
required_resource: instagram_professional_account
token_type: instagram_user
capability: instagram.post.comments
platform: instagram
scope: own_account
@@ -97,6 +103,11 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/comments
- id: instagram.instagram.account.insights
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_insights]
required_resource: instagram_professional_account
token_type: instagram_user
capability: instagram.account.insights
platform: instagram
scope: own_account
@@ -123,6 +134,11 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/insights
- id: instagram.instagram.media.insights
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_insights]
required_resource: instagram_professional_account
token_type: instagram_user
capability: instagram.media.insights
platform: instagram
scope: own_account
@@ -146,42 +162,60 @@ endpoints:
# --- direct messages (the `manage` capability, scope instagram_manage_messages) -------------
- id: instagram.x.user-messages
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_messages]
required_resource: instagram_professional_account
token_type: instagram_user
authorization_paths:
instagram-login: /{ig_user_id}/conversations
facebook-page: /{page_id}/conversations
capability: instagram.messages.list
platform: instagram
scope: own_account
method: GET
path: /{page_id}/conversations
path: /{ig_user_id}/conversations
name: "Your Instagram Direct message threads"
summary: "List Instagram Direct conversations that customers started with your account"
input:
pathParams:
page_id: {type: string, required: true, note: "numeric Facebook Page id linked to the Instagram professional account — not the Instagram account id or @handle; get it from /me/accounts alongside instagram_business_account", example: "123456789012345"}
ig_user_id: {type: string, required: true, authorization_methods: [instagram-login], note: "required with Instagram Login: numeric Instagram Professional account id", example: "17841400000000000"}
page_id: {type: string, required: true, authorization_methods: [facebook-page], note: "required with Facebook Page authorization: linked Facebook Page id", example: "123456789012345"}
queryParams:
platform: {type: string, required: true, note: "must be instagram for Instagram Direct conversations", example: "instagram"}
platform: {type: string, required: true, authorization_methods: [facebook-page], note: "required with Facebook Page authorization; selects Instagram rather than Messenger conversations", example: instagram}
fields: {type: string, required: false, note: "request participants and messages together when preparing a reply", example: "id,updated_time,participants,messages{id,from,to,message,created_time}"}
limit: {type: integer, required: false, example: 25}
note: "needs instagram_manage_messages and must target the linked Facebook Page id with that Page's access token, which Treg derives server-side when the Instagram account is selected. Targeting the Instagram account id returns Meta error (#3) even with the right token. Conversation ids are Graph API ids; an instagram.com/direct/t/... browser thread id is not interchangeable"
note: "Direct Instagram Login uses the Instagram Professional account id and an Instagram User token. Existing Facebook-backed connections use the linked Page id, add platform=instagram, and keep the same public endpoint id. Conversation ids are Graph API ids; an instagram.com/direct/t/... browser thread id is not interchangeable."
cost: {type: free, value: 0, currency: USD, unit: call, note: included with the connected account / platform rate limits apply}
unverified: "requires an account with a real customer-initiated Instagram conversation; verify deliberately in the App Review demo"
docs_url: https://developers.facebook.com/docs/messenger-platform/instagram/features/conversation
- id: instagram.instagram.message.send
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_manage_messages]
required_resource: instagram_professional_account
token_type: instagram_user
authorization_paths:
instagram-login: /{ig_user_id}/messages
facebook-page: /{page_id}/messages
kind: action
capability: instagram.message.send
platform: instagram
scope: own_account
method: POST
path: /{page_id}/messages
path: /{ig_user_id}/messages
name: "Reply in an Instagram Direct conversation"
summary: "Send an Instagram Direct reply to a customer who has messaged your account"
input:
pathParams:
page_id: {type: string, required: true, note: "numeric Facebook Page id linked to the Instagram professional account — the same Page id used by instagram.x.user-messages", example: "123456789012345"}
ig_user_id: {type: string, required: true, authorization_methods: [instagram-login], note: "required with Instagram Login: numeric Instagram Professional account id", example: "17841400000000000"}
page_id: {type: string, required: true, authorization_methods: [facebook-page], note: "required with Facebook Page authorization: linked Facebook Page id", example: "123456789012345"}
body:
recipient: {type: object, required: true, note: "the recipient's Instagram-scoped id (IGSID) obtained from a conversation participant or webhook — not a username, browser thread id, or your own account id", example: {id: "987654321012345"}}
message: {type: object, required: true, note: "message payload; this text form is the simplest App Review demonstration", example: {text: "Thanks for your message — this reply was sent through treg."}}
bodyType: json
note: "needs instagram_manage_messages and must target the linked Facebook Page id with Treg's derived Page token. This is a reply surface, not cold outreach: the recipient must have initiated contact and Meta's messaging-window/policy rules apply. Demonstrate the incoming message, this Try call, and the delivered reply in Instagram"
note: "Direct Instagram Login uses the Instagram Professional account id and an Instagram User token. Existing Facebook-backed connections keep the Page route and Page token. This is a reply surface, not cold outreach: the recipient must have initiated contact and Meta's messaging-window and policy rules apply."
cost: {type: free, value: 0, currency: USD, unit: call, note: included with the connected account / platform rate limits apply}
unverified: "write action — sends a real DM; requires a deliberate manual App Review test, never an automated verify run"
docs_url: https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
@@ -203,6 +237,11 @@ endpoints:
# Meta caps an account at 50 published posts per rolling 24 hours; the remaining allowance is
# readable at GET /{ig_user_id}/content_publishing_limit. Containers expire 24h after creation.
- id: instagram.instagram.media.container.create
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_content_publish]
required_resource: instagram_professional_account
token_type: instagram_user
kind: action
capability: instagram.media.container.create
platform: instagram
@@ -230,6 +269,11 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/content-publishing
- id: instagram.instagram.media.container.status
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_content_publish]
required_resource: instagram_professional_account
token_type: instagram_user
kind: utility
capability: instagram.media.container.status
platform: instagram
@@ -249,6 +293,11 @@ endpoints:
docs_url: https://developers.facebook.com/docs/instagram-platform/reference/instagram-media
- id: instagram.instagram.post.publish
authorization_method: instagram-login
authorization_methods: [instagram-login, facebook-page]
required_scopes: [instagram_business_basic, instagram_business_content_publish]
required_resource: instagram_professional_account
token_type: instagram_user
kind: action
capability: instagram.post.create
platform: instagram
+7 -1
View File
@@ -2297,6 +2297,8 @@ def cmd_call(args, cfg) -> None:
except ValueError:
pass
headers = {"content-type": ctype} if ctype else {}
if authorization_method := getattr(args, "authorization_method", None):
headers["X-Treg-Authorization-Method"] = authorization_method
# Some APIs need a caller-supplied header the binding can't know: Google Ads wants
# `login-customer-id` naming the manager account whenever you act on a client under an MCC,
# and it changes per call, so it can't live on the tool. Injected bindings still win — a
@@ -4944,6 +4946,8 @@ def cmd_oauth_connect(args, cfg) -> None:
_show(r)
return
d = r.json()
if d.get("connect_guidance"):
print(f"\n{d['connect_guidance']}")
print(f"\n1. Ensure this redirect URI is allowed:\n {d['redirect_uri']}")
print(f"\n2. Open to authorize:\n {d['consent_url']}\n\nWaiting…")
for _ in range(150):
@@ -5349,6 +5353,8 @@ def build_parser() -> argparse.ArgumentParser:
cl.add_argument("--method", default=None,
help="HTTP method (default: GET, or POST when --data/--file/--upload is given)")
cl.add_argument("--query", action="append", default=[], metavar="K=V", help="a query param (repeatable)")
cl.add_argument("--authorization-method", metavar="METHOD",
help="select an authorization method declared by the catalog endpoint")
cl.add_argument("--data", help="request body (string)"); cl.add_argument("--file", help="request body from a file")
cl.add_argument("--content-type", dest="content_type", metavar="TYPE",
help="Content-Type for the body (default: sniffed — a body that parses as JSON sends application/json)")
@@ -5654,7 +5660,7 @@ def build_parser() -> argparse.ArgumentParser:
def _connect_args(parser, prefix):
parser.add_argument("name", nargs="?", help="a name for the resulting oauth secret (default: the provider service)")
parser.add_argument("--provider", help=f"a registry service id — see `{prefix} providers`")
parser.add_argument("--capability", help="which scope set to request (default: read)")
parser.add_argument("--capability", help="scope set to request (default: provider-specific)")
parser.add_argument("--client-secret", help="path to your own OAuth client-secret JSON (bring-your-own-app)")
parser.add_argument("--scopes", nargs="+", default=[], help="one or more OAuth scopes (with --client-secret)")
parser.set_defaults(fn=cmd_oauth_connect)
+7 -6
View File
@@ -377,14 +377,15 @@ class Settings(BaseSettings):
# points at a sandbox client while production points at the reviewed one.
tiktok_client_id: str = ""
tiktok_client_secret: str = ""
# ONE Meta app backs both the facebook and instagram providers. Meta App Review, business
# verification and Tech Provider status are all scoped to the app, not to an OAuth client, so a
# second client would isolate nothing and would need its own review from zero. Instagram
# Business is reached through Facebook Login (graph.facebook.com), not the separate
# Instagram-Login API on graph.instagram.com — that is a different token family entirely and
# will reject these credentials outright.
# Facebook Login credentials. These continue to back Facebook Pages, Meta Ads, and the optional
# Page-based Instagram authorization method.
meta_client_id: str = ""
meta_client_secret: str = ""
# Business Login for Instagram has its own Instagram App ID and App Secret. Meta documents
# these under Instagram → API setup with Instagram login. They are not interchangeable with
# the Facebook Login app credentials above.
instagram_client_id: str = ""
instagram_client_secret: str = ""
# Advertising OAuth platforms — unset by default, so these providers list as "not configured"
# until this deployment registers its own developer app on each network.
microsoft_ads_client_id: str = ""
+33 -5
View File
@@ -460,6 +460,14 @@ def _normalize(raw: dict, provider: str, directory: Path) -> dict:
"name": str(raw.get("name") or "").strip(),
"summary": raw.get("summary") or "",
"input": raw.get("input") or {},
# Provider-owned OAuth routes can support more than one explicit grant. Keep this generic
# metadata on the normalized row so resolution and every client read the same contract.
"authorization_method": str(raw.get("authorization_method") or ""),
"authorization_methods": list(raw.get("authorization_methods") or []),
"authorization_paths": dict(raw.get("authorization_paths") or {}),
"required_scopes": list(raw.get("required_scopes") or []),
"required_resource": str(raw.get("required_resource") or ""),
"token_type": str(raw.get("token_type") or ""),
# the request the verifier actually made — a proven set of values, which is what makes
# `call_template` a paste-ready line rather than a shape with placeholders in it
"test_request": raw.get("test_request") or {},
@@ -537,6 +545,14 @@ def endpoint_view(ep: dict, provider_display: str, cat: Catalog | None = None) -
"tier": ep["tier"],
# data | action | account | utility — the front-end hides account/utility behind an expander
"kind": ep.get("kind") or DEFAULT_KIND,
**({
"authorization_method": ep.get("authorization_method") or None,
"authorization_methods": ep.get("authorization_methods") or [],
"authorization_paths": ep.get("authorization_paths") or {},
"required_scopes": ep.get("required_scopes") or [],
"required_resource": ep.get("required_resource") or None,
"token_type": ep.get("token_type") or None,
} if ep.get("authorization_methods") or ep.get("authorization_method") else {}),
# the section of its platform page this row files under (see `_domain`)
"domain": ep.get("domain") or DOMAIN_OTHER,
# the whole point of a row is the call it stands for, so the line that makes it is part of
@@ -1058,13 +1074,15 @@ def query_values(ep: dict | None, name: str, value) -> list[str]:
return [wire_value(item) for item in value if item is not None]
def _required_examples(params) -> dict:
def _required_examples(params, authorization_method: str = "") -> dict:
"""Required params only, valued by their documented example (else a typed placeholder). Optional
params are omitted — the template is the SHORTEST call that works, not a parameter reference."""
if not isinstance(params, dict):
return {}
return {k: _placeholder(v) for k, v in params.items()
if isinstance(v, dict) and v.get("required")}
if isinstance(v, dict) and v.get("required")
and (not v.get("authorization_methods")
or authorization_method in v["authorization_methods"])}
def call_template(ep: dict) -> str:
@@ -1079,14 +1097,23 @@ def call_template(ep: dict) -> str:
"""
inp = ep.get("input") or {}
test = ep.get("test_request") or {}
authorization_methods = tuple(ep.get("authorization_methods") or ())
authorization_method = str(ep.get("authorization_method") or "")
if not authorization_method and authorization_methods:
authorization_method = authorization_methods[0]
parts = ["treg call", ep["id"]]
if ep["method"] != "GET":
parts += ["--method", ep["method"]]
# A static catalog template cannot know which grants this team already connected. Let the
# resolver choose the sole existing grant, or the ordered default when both/none exist. A
# single-method endpoint stays explicit because there is no alternate route to suppress.
if authorization_method and len(authorization_methods) <= 1:
parts += ["--authorization-method", authorization_method]
# Path params first (the server folds them into the path), then query params proper.
for name, value in {**_required_examples(inp.get("pathParams")), **(test.get("pathParams") or {})}.items():
for name, value in {**_required_examples(inp.get("pathParams"), authorization_method), **(test.get("pathParams") or {})}.items():
parts += ["--query", shlex.quote(f"{name}={wire_value(value)}")]
for name, value in {**_required_examples(inp.get("queryParams")), **(test.get("queryParams") or {})}.items():
for name, value in {**_required_examples(inp.get("queryParams"), authorization_method), **(test.get("queryParams") or {})}.items():
for encoded in query_values(ep, name, value):
# Quote the COMPLETE argv token, not just its value. `name=['US']` lost its inner
# quotes after shell parsing and became either an unmatched glob (zsh) or `[US]`;
@@ -1094,7 +1121,8 @@ def call_template(ep: dict) -> str:
# same request the structured MCP path sends.
parts += ["--query", shlex.quote(f"{name}={encoded}")]
body = test.get("body") if test.get("body") is not None else (_required_examples(inp.get("body")) or None)
body = test.get("body") if test.get("body") is not None else (
_required_examples(inp.get("body"), authorization_method) or None)
# `is not None`, not truthiness: `body: {}` is a real stored test request — a POST that takes no
# arguments but still requires a JSON body — and dropping `--data '{}'` from the line hands the
# reader a command that differs from the one that was tested, on handlers that reject an empty
@@ -0,0 +1,125 @@
"""Authorization-method rules for provider connections.
The provider registry supplies data. This module owns the reusable decisions for providers that
offer more than one grant protocol. It has no HTTP or dashboard dependency.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any
@dataclass(frozen=True)
class AuthorizationMethod:
"""One explicit grant method for a logical provider."""
name: str
display_name: str
capabilities: tuple[str, ...]
connection_name: str
description: str
connect_capability: str = ""
action_label: str = "Add account"
missing_message: str = ""
capability_intros: tuple[tuple[str, str], ...] = ()
capability_details: tuple[tuple[str, tuple[str, ...]], ...] = ()
scope_aliases: tuple[tuple[str, str], ...] = ()
scope_riders: tuple[str, ...] = ()
scope_riders_by_scope: tuple[tuple[str, str], ...] = ()
overrides: tuple[tuple[str, object], ...] = ()
def method_for_capability(provider: Any, capability: str) -> AuthorizationMethod | None:
"""Return the grant method that owns a capability."""
matches = [method for method in provider.authorization_methods
if capability in method.capabilities]
if len(matches) > 1:
raise ValueError(
f"{provider.service} capability {capability!r} has multiple authorization methods"
)
if not matches:
if provider.authorization_methods:
raise ValueError(
f"{provider.service} capability {capability!r} has no authorization method"
)
return None
return matches[0]
def method_name(provider: Any, stored: str) -> str:
"""Normalize a stored method, including the provider's declared legacy value."""
if stored or not provider.authorization_methods:
return stored
return provider.legacy_authorization_method or method_for_capability(
provider, provider.default_capability
).name
def provider_profile(provider: Any, method: str) -> Any:
"""Return the protocol profile for one grant method."""
if not provider.authorization_methods:
return provider
name = method_name(provider, method)
selected = next(
(item for item in provider.authorization_methods if item.name == name), None
)
if selected is None:
raise ValueError(f"{provider.service} has no authorization method {name!r}")
return replace(
provider,
**dict(selected.overrides),
authorization_methods=(),
default_capability_name="",
)
def endpoint_methods(endpoint: dict) -> tuple[str, ...]:
"""Return supported methods with the endpoint default first."""
methods = endpoint.get("authorization_methods") or []
if not methods and endpoint.get("authorization_method"):
methods = [endpoint["authorization_method"]]
default = endpoint.get("authorization_method") or ""
if not default:
return tuple(methods)
return tuple([default] + [method for method in methods if method != default])
def select_endpoint_methods(
endpoint: dict, requested: str,
) -> tuple[str, ...]:
"""Validate an optional caller choice and return the methods to try in order."""
methods = endpoint_methods(endpoint)
selected = requested.strip().lower()
if not selected:
return methods
if not methods:
raise ValueError(
f"{endpoint['id']} does not support authorization-method selection"
)
if selected not in methods:
raise ValueError(
f"{endpoint['id']} does not support {selected}; choose " + " or ".join(methods)
)
return (selected,)
def method_spec(provider: Any, method: str) -> AuthorizationMethod | None:
"""Return presentation and scope metadata for one grant method."""
return next(
(item for item in provider.authorization_methods if item.name == method), None
)
def required_scopes(endpoint: dict, method: AuthorizationMethod | None) -> list[str]:
"""Translate endpoint scopes into the selected grant's scope dialect."""
declared = list(endpoint.get("required_scopes") or [])
if method is None:
return declared
aliases = dict(method.scope_aliases)
result = [aliases.get(scope, scope) for scope in declared]
result.extend(method.scope_riders)
result.extend(
rider for source, rider in method.scope_riders_by_scope if source in declared
)
return list(dict.fromkeys(result))
+33
View File
@@ -0,0 +1,33 @@
"""Pure OAuth consent-flow rules."""
from __future__ import annotations
import base64
import hashlib
import json
from urllib.parse import urlencode
def pkce_challenge(verifier: str) -> str:
"""Return the S256 challenge for a PKCE verifier."""
digest = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def consent_url(pending) -> str:
"""Build the provider consent URL from a stored pending connection."""
query = {
getattr(pending, "client_id_param", "") or "client_id": pending.client_id,
"redirect_uri": pending.redirect_uri,
"response_type": "code",
"scope": pending.scopes,
"state": pending.state,
}
query.update(
json.loads(pending.auth_params)
if pending.auth_params else {"access_type": "offline", "prompt": "consent"}
)
if pending.code_verifier:
query["code_challenge"] = pkce_challenge(pending.code_verifier)
query["code_challenge_method"] = "S256"
return f"{pending.auth_uri}?{urlencode(query)}"
+2
View File
@@ -111,6 +111,7 @@ def connection_view(secret: Secret) -> dict:
"name": secret.name,
"kind": secret.kind,
"provider": secret.provider,
"authorization_method": secret.authorization_method or "default",
"resource_name": secret.resource_name,
# The single field a UI or agent should act on: this connection will stop working and
# only a human re-consent can fix it.
@@ -118,6 +119,7 @@ def connection_view(secret: Secret) -> dict:
"resource_ref": secret.resource_ref,
"scopes": secret.granted_scopes.split() if secret.granted_scopes else [],
"health": secret.health_status,
"health_detail": secret.health_detail,
"refreshable": refreshable,
"expiry_state": state,
"expires_at": secret.expires_at.isoformat() if secret.expires_at else None,
+115
View File
@@ -0,0 +1,115 @@
"""HTTP adapter for the initial OAuth token exchange."""
from __future__ import annotations
import time
import httpx
from .. import crypto
class HTTPXOAuthExchangePort:
def __init__(self, client: httpx.AsyncClient):
self.client = client
async def exchange_code(self, pending, code: str) -> dict:
"""Trade an authorization code for the provider's durable token shape."""
client_secret = crypto.decrypt(pending.client_secret)
client_id_param = getattr(pending, "client_id_param", "") or "client_id"
data = {
"code": code,
client_id_param: pending.client_id,
"redirect_uri": pending.redirect_uri,
"grant_type": "authorization_code",
}
if pending.code_verifier:
data["code_verifier"] = pending.code_verifier
kwargs: dict = {}
if pending.token_endpoint_auth_method == "client_secret_basic":
kwargs["auth"] = (pending.client_id, client_secret)
else:
data["client_secret"] = client_secret
response = await self.client.post(pending.token_uri, data=data, **kwargs)
response.raise_for_status()
token = response.json()
access = token.get("access_token")
if not access:
raise ValueError(
f"token endpoint returned no access_token: {token.get('error') or token}"
)
blob = {
"access_token": access,
"token": access,
"refresh_token": token.get("refresh_token"),
"client_id": pending.client_id,
"client_secret": client_secret,
"token_uri": pending.token_uri,
"expires_at": time.time() + float(token.get("expires_in") or 3600),
}
if client_id_param != "client_id":
blob["client_id_param"] = client_id_param
if (pending.token_endpoint_auth_method
and pending.token_endpoint_auth_method != "client_secret_post"):
blob["token_endpoint_auth_method"] = pending.token_endpoint_auth_method
style = getattr(pending, "long_lived_exchange_style", "")
if style == "instagram":
return await self._extend_instagram_token(blob)
if getattr(pending, "long_lived_exchange", False):
return await self._extend_meta_token(blob)
return blob
async def _extend_instagram_token(self, blob: dict) -> dict:
response = await self.client.get(
"https://graph.instagram.com/access_token",
params={
"grant_type": "ig_exchange_token",
"client_secret": blob["client_secret"],
"access_token": blob["access_token"],
},
)
response.raise_for_status()
token = response.json()
access = token.get("access_token")
if not access:
raise ValueError(
f"Instagram long-lived exchange returned no access_token: {token}"
)
return {
**blob,
"access_token": access,
"token": access,
"refresh_token": access,
"token_uri": "https://graph.instagram.com/refresh_access_token",
"refresh_method": "GET",
"refresh_grant_type": "ig_refresh_token",
"refresh_token_param": "access_token",
"refresh_include_client": False,
"expires_at": time.time() + float(token.get("expires_in") or 5_184_000),
}
async def _extend_meta_token(self, blob: dict) -> dict:
response = await self.client.get(
blob["token_uri"],
params={
"grant_type": "fb_exchange_token",
"client_id": blob["client_id"],
"client_secret": blob["client_secret"],
"fb_exchange_token": blob["access_token"],
},
)
if response.status_code != 200:
return blob
token = response.json()
access = token.get("access_token")
if not access:
return blob
return {
**blob,
"access_token": access,
"token": access,
"expires_at": (
time.time() + float(token["expires_in"])
if token.get("expires_in") else None
),
}
+13 -3
View File
@@ -22,7 +22,12 @@ class HTTPXOAuthRefreshPort:
# refresh months later still speaks the dialect the grant was minted with.
cid_param = blob.get("client_id_param") or "client_id"
token_uri = blob.get("token_uri", _DEFAULT_TOKEN_URI)
data = {"grant_type": "refresh_token", "refresh_token": rt, cid_param: cid}
grant_type = blob.get("refresh_grant_type") or "refresh_token"
refresh_param = blob.get("refresh_token_param") or "refresh_token"
include_client = blob.get("refresh_include_client", True)
data = {"grant_type": grant_type, refresh_param: rt}
if include_client:
data[cid_param] = cid
# Client authentication must match what the provider demands, same as exchange_code: X and
# Pinterest REQUIRE HTTP Basic and reject a secret in the body. This bit the connect path first
@@ -34,9 +39,12 @@ class HTTPXOAuthRefreshPort:
method = blob.get("token_endpoint_auth_method")
async def _post(basic: bool) -> httpx.Response:
if (blob.get("refresh_method") or "POST").upper() == "GET":
return await self.client.get(token_uri, params=data)
if basic:
return await self.client.post(token_uri, data=data, auth=(cid, csec))
return await self.client.post(token_uri, data={**data, "client_secret": csec})
body = {**data, "client_secret": csec} if include_client else data
return await self.client.post(token_uri, data=body)
tried_basic = method == "client_secret_basic"
resp = await _post(basic=tried_basic)
@@ -56,6 +64,8 @@ class HTTPXOAuthRefreshPort:
# Always stamp an expiry (fallback 1h). A provider that omits/nulls expires_in would otherwise
# leave the token perpetually "unknown expiry" → is_stale True → a live refresh on EVERY call.
new["expires_at"] = time.time() + float(tok.get("expires_in") or 3600)
if tok.get("refresh_token"): # providers may rotate the refresh token
if blob.get("refresh_token_param") == "access_token":
new["refresh_token"] = access
elif tok.get("refresh_token"): # providers may rotate the refresh token
new["refresh_token"] = tok["refresh_token"]
return new
+13 -3
View File
@@ -766,10 +766,12 @@ async def call(endpoint_id: str, params: dict | list | None = None,
method: str | None = None, idempotency_key: str | None = None,
query: dict | None = None, body: dict | list | str | None = None,
headers: dict | None = None, content_type: str | None = None,
authorization_method: str | None = None,
ctx: Context = None) -> CallOut: # type: ignore[assignment]
return await _call_impl(
endpoint_id, params=params, method=method, idempotency_key=idempotency_key,
query=query, body=body, headers=headers, content_type=content_type, ctx=ctx,
query=query, body=body, headers=headers, content_type=content_type,
authorization_method=authorization_method, ctx=ctx,
catalog_only=False, surface=_TEAM_SURFACE, allowed_methods=None,
)
@@ -778,6 +780,7 @@ async def _call_impl(endpoint_id: str, params: dict | list | None = None,
method: str | None = None, idempotency_key: str | None = None,
query: dict | None = None, body: dict | list | str | None = None,
headers: dict | None = None, content_type: str | None = None,
authorization_method: str | None = None,
ctx: Context = None, *, catalog_only: bool,
allowed_methods: frozenset[str] | None,
surface: _SurfacePolicy) -> CallOut:
@@ -857,7 +860,10 @@ async def _call_impl(endpoint_id: str, params: dict | list | None = None,
# model fills in — a model that omits it mid-chain drops that spend out of its user's invoice.
extra_headers = {k: str(v) for k, v in (headers or {}).items()
if k.lower() not in ("x-treg-token", "x-treg-org", "authorization",
"idempotency-key", "x-treg-meta")}
"idempotency-key", "x-treg-meta",
"x-treg-authorization-method")}
if authorization_method:
extra_headers["X-Treg-Authorization-Method"] = authorization_method
if isinstance(the_body, str):
# A raw string body travels as-is. Content-Type: explicit wins, else sniff JSON — the
# CLI's rule, because upstreams that require `application/json` reject a JSON body
@@ -1109,10 +1115,12 @@ async def directory_catalog_call_read(
idempotency_key: str | None = None,
query: dict | None = None,
headers: dict | None = None,
authorization_method: str | None = None,
ctx: Context = None, # type: ignore[assignment]
) -> CallOut:
return await _call_impl(
endpoint_id, params=params, idempotency_key=idempotency_key, query=query, headers=headers,
authorization_method=authorization_method,
ctx=ctx, catalog_only=True, surface=_DIRECTORY_SURFACE,
allowed_methods=frozenset({"GET", "HEAD", "OPTIONS"}),
)
@@ -1138,11 +1146,13 @@ async def directory_catalog_call_write(
body: dict | list | str | None = None,
headers: dict | None = None,
content_type: str | None = None,
authorization_method: str | None = None,
ctx: Context = None, # type: ignore[assignment]
) -> CallOut:
return await _call_impl(
endpoint_id, params=params, idempotency_key=idempotency_key, query=query, body=body,
headers=headers, content_type=content_type, ctx=ctx, catalog_only=True,
headers=headers, content_type=content_type, authorization_method=authorization_method,
ctx=ctx, catalog_only=True,
surface=_DIRECTORY_SURFACE,
allowed_methods=frozenset({"POST", "PUT", "PATCH", "DELETE"}),
)
+9
View File
@@ -378,6 +378,9 @@ class PendingOAuth(SQLModel, table=True):
# The registry service this connect came from ("" for a bring-your-own-app connect). Carried
# through the redirect so the callback knows which provider's tool to auto-provision.
provider: str = Field(default="")
# One catalog provider can have more than one explicit OAuth grant. Instagram uses a
# direct Instagram grant by default and a separate Facebook Page grant for Page-only tools.
authorization_method: str = Field(default="")
# Per-provider auth quirks, captured at start so the callback exchanges the code the same way
# the consent URL was built. `code_verifier` is PKCE (empty = not used); `auth_params` is a JSON
# object of extra consent-URL query params.
@@ -392,6 +395,9 @@ class PendingOAuth(SQLModel, table=True):
# Snapshotted for the same reason as the fields above — the callback must not have to look the
# provider up again to know how the token was meant to be obtained.
long_lived_exchange: bool = Field(default=False)
# Empty keeps the legacy boolean behavior. "instagram" uses Instagram's ig_exchange_token
# endpoint and records its renewable long-lived-token protocol in the encrypted blob.
long_lived_exchange_style: str = Field(default="")
# Which existing connection this consent is REPLACING, if any. Set when the user reconnects or
# widens access on a specific account; left null when they are adding another one. Without it
# the callback cannot tell "renew this Slack workspace" from "attach a second Slack workspace",
@@ -424,6 +430,9 @@ class Secret(SQLModel, table=True):
# Connection metadata (registry connects — see oauth_providers.py). Empty `provider` means this
# credential did not come from the registry (uploaded, or a bring-your-own-app connect).
provider: str = Field(default="", index=True)
# The explicit grant that minted this credential. Kept separate from provider so two grants for
# one logical provider never overwrite or impersonate each other.
authorization_method: str = Field(default="", index=True)
granted_scopes: str = Field(default="") # space-joined; what the user ACTUALLY consented to
resource_ref: str = Field(default="") # the chosen site / property / account this connection acts on
# The human name for that ref. Upstream ids are opaque ("properties/384078430"); showing one to
+10 -128
View File
@@ -1,24 +1,15 @@
"""OAuth freshness — treg owns keeping tokens alive. The injector stays dumb; this runs in the
api layer just before a call, and in the health runner.
"""Compatibility facade for connection OAuth helpers.
An oauth secret is a SELF-REFRESHABLE blob:
{access_token|token, refresh_token, expires_at|expiry, token_uri, client_id, client_secret}
`ensure_fresh` refreshes in place (re-encrypt + persist) when the token is stale. A single-flight
lock per secret id prevents a refresh stampede when many calls hit an expired token at once.
New code belongs in ``domain.connections`` and ``infra``. Existing imports can use this module while
callers move to those boundaries.
"""
from __future__ import annotations
import base64
import hashlib
import json
import time
from datetime import datetime, timezone
from urllib.parse import urlencode
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from . import crypto
from .domain.connections.oauth_flow import consent_url, pkce_challenge
from .domain.connections.refresh import (
EXPIRING_SOON_DAYS,
_DEFAULT_TOKEN_URI,
@@ -33,127 +24,18 @@ from .domain.connections.refresh import (
is_stale,
secret_is_refreshable,
)
from .infra.oauth_exchange import HTTPXOAuthExchangePort
from .infra.oauth_refresh import HTTPXOAuthRefreshPort
from .models import PendingOAuth, Secret
async def exchange_code(pending: PendingOAuth, code: str, client: httpx.AsyncClient) -> dict:
return await HTTPXOAuthExchangePort(client).exchange_code(pending, code)
async def refresh(blob: dict, client: httpx.AsyncClient) -> dict:
return await HTTPXOAuthRefreshPort(client).exchange(blob)
async def ensure_fresh(secret: Secret, db: AsyncSession, client: httpx.AsyncClient) -> None:
await _ensure_fresh(secret, db, HTTPXOAuthRefreshPort(client))
# ---- connect flow (Phase C): mint the first token via browser consent --------------------
def pkce_challenge(verifier: str) -> str:
"""S256 challenge for a PKCE verifier (base64url, no padding)."""
digest = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def consent_url(p: PendingOAuth) -> str:
"""The provider consent URL the user opens.
access_type=offline + prompt=consent are Google's way of guaranteeing a refresh_token, so the
credential lands in auto-refresh mode. Providers that want different parameters carry them on
the registry entry (`auth_params`), which replaces these defaults entirely."""
q = {
# TikTok reads `client_key`; everyone else reads the OAuth2 `client_id`.
getattr(p, "client_id_param", "") or "client_id": p.client_id,
"redirect_uri": p.redirect_uri,
"response_type": "code",
# `scopes` is stored in the provider's own delimiter (space, or comma for TikTok), so it
# goes onto the URL verbatim — re-joining here would undo that.
"scope": p.scopes,
"state": p.state,
}
q.update(json.loads(p.auth_params) if p.auth_params else {"access_type": "offline", "prompt": "consent"})
if p.code_verifier: # PKCE — X rejects an authorization code exchanged without it
q["code_challenge"] = pkce_challenge(p.code_verifier)
q["code_challenge_method"] = "S256"
return f"{p.auth_uri}?{urlencode(q)}"
async def exchange_code(p: PendingOAuth, code: str, client: httpx.AsyncClient) -> dict:
"""Trade the authorization code for tokens; return a self-refreshable oauth blob."""
client_secret = crypto.decrypt(p.client_secret)
cid_param = getattr(p, "client_id_param", "") or "client_id"
data = {
"code": code,
cid_param: p.client_id,
"redirect_uri": p.redirect_uri,
"grant_type": "authorization_code",
}
if p.code_verifier:
data["code_verifier"] = p.code_verifier
kwargs: dict = {}
if p.token_endpoint_auth_method == "client_secret_basic":
# X's confidential clients REQUIRE HTTP Basic; sending the secret in the body is rejected.
kwargs["auth"] = (p.client_id, client_secret)
else:
data["client_secret"] = client_secret
resp = await client.post(p.token_uri, data=data, **kwargs)
resp.raise_for_status()
tok = resp.json()
access = tok.get("access_token")
if not access: # a 200 with an error-shaped body — surface the provider's reason, not a KeyError
raise ValueError(f"token endpoint returned no access_token: {tok.get('error') or tok}")
blob = {
"access_token": access,
"token": access,
"refresh_token": tok.get("refresh_token"),
# Always stored under the canonical key — `is_refreshable` and every reader look for
# "client_id". Only the wire spelling differs, and that travels as client_id_param below.
"client_id": p.client_id,
"client_secret": client_secret,
"token_uri": p.token_uri,
"expires_at": time.time() + float(tok.get("expires_in") or 3600),
}
if cid_param != "client_id": # TikTok — refresh must post client_key, not client_id
blob["client_id_param"] = cid_param
if p.token_endpoint_auth_method and p.token_endpoint_auth_method != "client_secret_post":
# X / Pinterest demand HTTP Basic at the token endpoint. refresh() has no PendingOAuth to
# ask months from now, so the blob itself must remember — omitting this is exactly the bug
# where connect succeeded and every refresh 401'd.
blob["token_endpoint_auth_method"] = p.token_endpoint_auth_method
if getattr(p, "long_lived_exchange", False):
blob = await _extend_meta_token(blob, client)
return blob
async def _extend_meta_token(blob: dict, client: httpx.AsyncClient) -> dict:
"""Swap Meta's short-lived user token for the ~60-day one.
Meta's authorization-code exchange returns a token good for an hour or two and no
refresh_token, so a connection made this way is dead by the time anyone uses it. This second
call is the only way to get a durable user credential out of Facebook Login.
A failure here is deliberately NOT fatal: the short-lived token is still a working credential,
and refusing the whole connect would be a worse outcome than a connection the user has to
remake sooner. `expires_at` keeps telling the truth either way, which is what `needs_reconnect`
reads.
"""
resp = await client.get(
blob["token_uri"],
params={
"grant_type": "fb_exchange_token",
"client_id": blob["client_id"],
"client_secret": blob["client_secret"],
"fb_exchange_token": blob["access_token"],
},
)
if resp.status_code != 200:
return blob
tok = resp.json()
access = tok.get("access_token")
if not access:
return blob
return {
**blob,
"access_token": access,
"token": access,
# Meta omits expires_in when it issues a non-expiring token (system users, some business
# tokens). Falling back to the 60-day default would then invent an expiry that isn't real
# and nag the user to reconnect a credential that never dies.
"expires_at": time.time() + float(tok["expires_in"]) if tok.get("expires_in") else None,
}
+179 -56
View File
@@ -19,6 +19,11 @@ from __future__ import annotations
from dataclasses import dataclass
from .config import platform_setting_name, get_settings
from .domain.connections import authorization as connection_authorization
# Compatibility name for callers that still import provider definitions from this legacy module.
OAuthAuthorizationMethod = connection_authorization.AuthorizationMethod
@dataclass(frozen=True)
@@ -139,6 +144,16 @@ class OAuthProvider:
# renewed unattended, so the connection surfaces through the same `needs_reconnect` path as
# LinkedIn's non-refreshable tokens rather than pretending it auto-heals.
long_lived_exchange: bool = False
# Instagram Login uses a different long-lived exchange and a renewable 60-day access token.
# Empty keeps the standard provider behavior; "instagram" snapshots that protocol on PendingOAuth.
long_lived_exchange_style: str = ""
# One logical catalog provider can expose explicit, separate grants. Each method selects a
# protocol profile without changing the provider id or duplicating its endpoint catalog.
authorization_methods: tuple[OAuthAuthorizationMethod, ...] = ()
default_capability_name: str = ""
# Method assigned to grants created before explicit method identity existed. Empty means the
# provider's normal default. This keeps compatibility policy in provider metadata.
legacy_authorization_method: str = ""
# Some providers need a SECOND credential alongside the user's OAuth token — Google Ads wants a
# developer-token header from an approved MCC. We can't auto-provision a working tool from the
@@ -204,6 +219,10 @@ class OAuthProvider:
# listing is ignored because an older grant can lack its scope while the primary result is valid.
discover_extra_path: str = ""
discover_extra_list_paths: tuple[str, ...] = ()
# An empty successful listing is still non-working for providers whose grant must target one
# discovered account. The detail is safe user-facing setup guidance.
discovery_required: bool = False
empty_resource_detail: str = ""
# Most OAuth providers call their API with the token returned by the token endpoint. A provider
# can instead declare a private lookup that derives a resource-scoped call credential after the
@@ -270,6 +289,11 @@ class OAuthProvider:
identity_id_path: str = "" # dotted path to the id, e.g. "sub"
identity_label_path: str = "" # dotted path to the display name, e.g. "name"
identity_ref_format: str = "{id}" # e.g. "urn:li:person:{id}"
# A successful token exchange is not a usable connection when this lookup returns no account.
identity_required: bool = False
# Keep a required-identity failure beside the identity protocol. The shared callback must not
# need provider names to explain why a completed consent did not yield a callable account.
identity_missing_detail: str = ""
@property
def is_token_kind(self) -> bool:
@@ -305,8 +329,19 @@ class OAuthProvider:
# empty sequence and take the whole /oauth/providers listing down with it.
if not self.scopes:
return ""
if self.default_capability_name:
return self.default_capability_name
return max(self.capabilities, key=lambda c: len(self.scopes[c]))
def authorization_for_capability(self, capability: str) -> OAuthAuthorizationMethod | None:
return connection_authorization.method_for_capability(self, capability)
def profile_for_authorization(self, method: str) -> "OAuthProvider":
return connection_authorization.provider_profile(self, method)
def authorization_method_name(self, stored: str) -> str:
return connection_authorization.method_name(self, stored)
@property
def resource_plural(self) -> str:
return self.resource_label_plural or f"{self.resource_label}s"
@@ -799,6 +834,9 @@ TIKTOK = OAuthProvider(
_META_AUTH = "https://www.facebook.com/v25.0/dialog/oauth"
_META_TOKEN = "https://graph.facebook.com/v25.0/oauth/access_token"
_META_BASE = "https://graph.facebook.com/v25.0"
_INSTAGRAM_AUTH = "https://www.instagram.com/oauth/authorize"
_INSTAGRAM_TOKEN = "https://api.instagram.com/oauth/access_token"
_INSTAGRAM_BASE = "https://graph.instagram.com/v25.0"
# The Meta app behind all three providers is registered as "Crewlet" — a sibling product from the
# same company — and Facebook's consent screen renders only that bare app name, with no parent
@@ -873,77 +911,131 @@ FACEBOOK = OAuthProvider(
INSTAGRAM = OAuthProvider(
service="instagram",
display_name="Instagram",
auth_uri=_META_AUTH,
token_uri=_META_TOKEN,
# instagram_basic alone cannot publish, and instagram_content_publish alone cannot read the
# account it publishes to — Meta enforces that dependency in App Review, so post is a strict
# superset rather than a swap.
# business_management is here for the same reason it is in _FB_READ: an agency member's
# Instagram accounts hang off Business-owned Pages that /me/accounts cannot see.
auth_uri=_INSTAGRAM_AUTH,
token_uri=_INSTAGRAM_TOKEN,
# Direct Instagram scopes are cumulative. `page-tools` is a separate grant, not a wider direct
# grant: it starts Facebook Login and is stored beside the direct Instagram credential.
scopes={
"read": [
"instagram_basic", "instagram_manage_insights", "pages_show_list",
"pages_read_engagement", "business_management",
"instagram_business_basic", "instagram_business_manage_insights",
],
"post": [
"instagram_basic", "instagram_manage_insights", "pages_show_list",
"pages_read_engagement", "business_management", "instagram_content_publish",
"instagram_business_basic", "instagram_business_manage_insights",
"instagram_business_content_publish",
],
# Adds the two-way surfaces: comment moderation and direct messages. Kept off `post` so a
# publish-only connect never puts "manage your messages" on the consent screen.
"manage": [
"instagram_basic", "instagram_manage_insights", "pages_show_list",
"pages_read_engagement", "business_management", "instagram_content_publish",
"instagram_manage_comments", "instagram_manage_messages", "pages_messaging",
"instagram_business_basic", "instagram_business_manage_insights",
"instagram_business_content_publish", "instagram_business_manage_comments",
"instagram_business_manage_messages",
],
"page-tools": [
"instagram_basic", "instagram_manage_insights", "instagram_content_publish",
"instagram_manage_comments", "instagram_manage_messages",
"instagram_shopping_tag_products", "pages_show_list", "pages_read_engagement",
"pages_messaging", "business_management",
],
},
client_id_setting="meta_client_id",
client_secret_setting="meta_client_secret",
client_id_setting="instagram_client_id",
client_secret_setting="instagram_client_secret",
category="Social media",
summary=(
"Your Instagram media, comments and insights, plus publishing to your account."
),
base_url=_META_BASE,
docs_url="https://developers.facebook.com/docs/instagram-platform/instagram-graph-api",
consent_notice=_META_CONSENT_NOTICE,
auth_params={},
long_lived_exchange=True,
base_url=_INSTAGRAM_BASE,
docs_url="https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-instagram-login",
consent_notice="Instagram will ask you to sign in to the professional account that you want to connect.",
auth_params={"enable_fb_login": "false"},
scope_separator=",",
long_lived_exchange_style="instagram",
default_capability_name="manage",
legacy_authorization_method="facebook-page",
authorization_methods=(
OAuthAuthorizationMethod(
name="instagram-login", display_name="Instagram Login",
capabilities=("read", "post", "manage"), connection_name="instagram",
description="Connect an Instagram Professional account directly. A Facebook Page is not required.",
),
OAuthAuthorizationMethod(
name="facebook-page", display_name="Facebook Page tools",
capabilities=("page-tools",), connection_name="instagram-page-tools",
description=(
"Facebook Page tools require an Instagram Professional account that is linked "
"to a Facebook Page. This authorization is separate from Instagram Login."
),
connect_capability="page-tools",
action_label="Enable Facebook Page tools",
missing_message=(
"This tool requires Facebook Page authorization and an Instagram Professional "
"account linked to that Page."
),
capability_intros=((
"page-tools",
"Also supports core Instagram access through the linked Page. Adds these Page-only tools:",
),),
capability_details=(("page-tools", (
"Search hashtags and read recent or top hashtag media",
"Discover another professional account by username",
"Read media, comments and tags that mention your account",
"Search the linked product catalog and inspect product appeals",
"Read this account's recently searched hashtags",
)),),
scope_aliases=(
("instagram_business_basic", "instagram_basic"),
("instagram_business_manage_insights", "instagram_manage_insights"),
("instagram_business_content_publish", "instagram_content_publish"),
("instagram_business_manage_comments", "instagram_manage_comments"),
("instagram_business_manage_messages", "instagram_manage_messages"),
),
scope_riders=("pages_read_engagement",),
scope_riders_by_scope=(("instagram_business_manage_messages", "pages_messaging"),),
overrides=(
("auth_uri", _META_AUTH), ("token_uri", _META_TOKEN),
("client_id_setting", "meta_client_id"),
("client_secret_setting", "meta_client_secret"),
("base_url", _META_BASE),
("docs_url", "https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-facebook-login"),
("consent_notice", _META_CONSENT_NOTICE), ("auth_params", {}),
("scope_separator", " "), ("long_lived_exchange", True),
("long_lived_exchange_style", ""),
("identity_path", ""), ("identity_id_path", ""),
("identity_label_path", ""), ("identity_required", False),
("discover_path", "/me/accounts?fields=instagram_business_account{id,username}"),
("discover_key", "data"),
("discover_id_field", "instagram_business_account.id"),
("discover_label_field", "instagram_business_account.username"),
("discover_extra_path", "/me/businesses?fields=owned_pages{instagram_business_account{id,username}},client_pages{instagram_business_account{id,username}}"),
("discover_extra_list_paths", _META_BIZ_PAGE_LISTS),
("discovery_required", True),
("empty_resource_detail", (
"No usable Instagram Professional account was returned. Confirm that the "
"account is linked to a Facebook Page that you can manage."
)),
("call_token_field", "page_access_token"),
("resource_token_path", "/me/accounts?fields=id,access_token,instagram_business_account{id}"),
("resource_token_id_field", "instagram_business_account.id"),
("resource_token_value_field", "access_token"),
("resource_token_context_fields", (("page_id", "id"),)),
("resource_token_extra_path", "/me/businesses?fields=owned_pages{id,access_token,instagram_business_account{id}},client_pages{id,access_token,instagram_business_account{id}}"),
("resource_token_extra_list_paths", _META_BIZ_PAGE_LISTS),
("resource_setup_method", "POST"),
("resource_setup_path", "/{page_id}/subscribed_apps"),
("resource_setup_scope", "pages_messaging"),
("resource_setup_payload", (("subscribed_fields", "messages,messaging_postbacks"),)),
("probe_path", "/me?fields=id,name"),
),
),
),
resource_label="account",
resource_label_plural="accounts",
# There is no endpoint that lists Instagram accounts directly: you list Pages and read the
# professional account linked to each. Pages with no linked account come back with the field
# absent, so the dotted id path yields nothing for them and they drop out of the picker.
discover_path="/me/accounts?fields=instagram_business_account{id,username}",
discover_key="data",
discover_id_field="instagram_business_account.id",
discover_label_field="instagram_business_account.username",
# The Business walk asks for the SAME nested field, so its flattened rows are again
# Page-shaped and the dotted id path above reads both listings unchanged.
discover_extra_path=(
"/me/businesses?fields=owned_pages{instagram_business_account{id,username}},"
"client_pages{instagram_business_account{id,username}}"
identity_path="/me?fields=user_id,username",
identity_id_path="user_id",
identity_label_path="username",
identity_required=True,
identity_missing_detail=(
"No usable Instagram Professional account was returned. Confirm that the account is a "
"Business or Creator account, then connect again."
),
discover_extra_list_paths=_META_BIZ_PAGE_LISTS,
# Meta exchanges the code for a USER token, but Instagram Graph calls are made as the Facebook
# Page linked to the selected professional account. Resolve that Page token server-side at pick
# time; the dashboard and calling agent only ever see the Instagram id and username.
call_token_field="page_access_token",
resource_token_path=(
"/me/accounts?fields=id,access_token,instagram_business_account{id}"
),
resource_token_id_field="instagram_business_account.id",
resource_token_value_field="access_token",
resource_token_context_fields=(("page_id", "id"),),
resource_token_extra_path=(
"/me/businesses?fields=owned_pages{id,access_token,instagram_business_account{id}},"
"client_pages{id,access_token,instagram_business_account{id}}"
),
resource_token_extra_list_paths=_META_BIZ_PAGE_LISTS,
resource_setup_method="POST",
resource_setup_path="/{page_id}/subscribed_apps",
resource_setup_scope="pages_messaging",
resource_setup_payload=(("subscribed_fields", "messages,messaging_postbacks"),),
probe_path="/me?fields=id,name",
probe_path="/me?fields=user_id,username",
)
META_ADS = OAuthProvider(
@@ -2490,6 +2582,11 @@ def credentials(provider: OAuthProvider) -> tuple[str, str]:
def is_configured(provider: OAuthProvider) -> bool:
"""Whether THIS deployment can offer the provider. A pasted-secret provider (bot token or API
key) needs nothing from us — the user brings their own — so it is always offerable."""
if provider.authorization_methods:
return any(
is_configured(provider.profile_for_authorization(method.name))
for method in provider.authorization_methods
)
if provider.uses_pasted_secret:
return True
try:
@@ -2571,6 +2668,13 @@ SCOPE_LABELS: dict[str, str] = {
"instagram_content_publish": "Publish posts to your Instagram account",
"instagram_manage_comments": "Reply to, hide and delete comments on your Instagram posts",
"instagram_manage_messages": "Read and reply to your Instagram direct messages",
"instagram_shopping_tag_products": "Use products from the catalog linked to your Instagram account",
# Meta — direct Instagram Login
"instagram_business_basic": "See your Instagram Professional account, media and comments",
"instagram_business_manage_insights": "Read your Instagram reach and engagement insights",
"instagram_business_content_publish": "Publish posts to your Instagram Professional account",
"instagram_business_manage_comments": "Reply to, hide and delete comments on your Instagram posts",
"instagram_business_manage_messages": "Read and reply to your Instagram direct messages",
# Meta — Ads
"ads_read": "Read your ad accounts, campaigns and performance",
"business_management": "See the businesses and ad accounts you have access to",
@@ -2633,6 +2737,25 @@ def listing() -> list[dict]:
"docs_url": p.docs_url,
"consent_notice": p.consent_notice,
"configured": is_configured(p),
"authorization_methods": [
{
"name": method.name,
"display_name": method.display_name,
"capabilities": list(method.capabilities),
"description": method.description,
"connect_capability": method.connect_capability,
"action_label": method.action_label,
"missing_message": method.missing_message,
"capability_intros": dict(method.capability_intros),
"capability_details": {
capability: list(details)
for capability, details in method.capability_details
},
"configured": is_configured(p.profile_for_authorization(method.name)),
"consent_notice": p.profile_for_authorization(method.name).consent_notice,
}
for method in p.authorization_methods
],
# Whether calls on this connection are metered from the team balance (the provider
# bills treg's app per use), with the default rates — shown BEFORE consent, so nobody
# connects an account without seeing the price. Off unless the deployment enables it.
+11 -92
View File
@@ -9,20 +9,15 @@ from fastapi.responses import Response, StreamingResponse
from starlette.background import BackgroundTask
from sqlalchemy.ext.asyncio import AsyncSession
from .. import audit, oauth_providers
from .. import audit
from .. import sandbox as demo_sandbox
from ..application.call.access import catalog_endpoint_access as get_catalog_endpoint_access
from ..application.call.idempotency import (
_release_idempotent_claim as release_idempotent_claim,
)
from ..application.call.resolve import (
QueryValues,
_enforce_catalog_status,
_marketplace_secret,
_may_have_body as may_have_body,
_platform_estimate_micro,
_platform_offer,
_resolve_call,
resolve_call_target,
)
from ..application.call.service import create_call_context, execute_call, _refusal_kind
from ..application.call.intake import (
@@ -33,8 +28,6 @@ from ..application.call.intake import (
from ..application.call.types import CallerSnapshot, CallFailure, CallInput, UpstreamResponse
from ..caller_metadata import _client_of
from ..config import get_settings
from ..domain import money as ledger
from ..domain.catalog import store as catalog_store
from ..domain.governance import access as access_policy
from ..domain.governance import publicdemo as publicdemo_policy
from ..domain.identity.access import Caller, require_member
@@ -167,93 +160,19 @@ async def _stamp_call_exit(request: Request, resp: Response, status_code: int) -
@app.get("/catalog/endpoints/{endpoint_id}/access", include_in_schema=False)
async def catalog_endpoint_access(
endpoint_id: str, caller: Caller = Depends(require_member), db: AsyncSession = Depends(get_session)
endpoint_id: str, authorization_method: str = "",
caller: Caller = Depends(require_member), db: AsyncSession = Depends(get_session)
) -> dict:
"""Authenticated dry-run of the marketplace credential ladder — which tier would serve YOU.
Read by `treg catalog get` to print an honest access line under RUN IT (the open catalog
endpoints stay unauthenticated; this one needs to know who is asking)."""
ep = catalog_store.load().by_id.get(endpoint_id)
if ep is None:
raise HTTPException(status_code=404, detail=f"unknown endpoint {endpoint_id!r}")
"""Translate the catalog access use case into HTTP."""
try:
_enforce_catalog_status(ep)
return await get_catalog_endpoint_access(
endpoint_id=endpoint_id,
authorization_method=authorization_method,
caller=caller,
db=db,
)
except CallFailure as exc:
raise _translate_call_failure(exc) from exc
service = ep["provider"]
if ep.get("kind") == "routed":
# The routed row's dry-run IS the plan for this org: which child would go first, and why.
from ..application.call.route import RouteOptions, build_plan
opts = RouteOptions.from_headers(lambda k: None)
plan = await build_plan(ep, dict(ep.get("test_request", {}).get("body") or {}), caller, opts)
if not plan.candidates:
# The example body is one identity shape; a team may hold keys for providers that take
# another. Try each variant of the contract before declaring the job unservable.
contract = catalog_store.load().contracts.get(ep.get("capability") or "")
for variant in (contract.identity if contract else []):
trial = await build_plan(ep, {k: "example" for k in variant}, caller, opts)
if trial.candidates:
plan = trial
break
if not plan.candidates:
return {"tier": "none", "detail": "no provider can serve any identity shape of this job for your team right now",
"dropped": plan.dropped}
first = plan.candidates[0]
how = ("your registered tool" if first.tier == "tool" else "your own credential" if first.tier == "credential"
else f"treg's {first.endpoint['provider']} key, ~${(first.price_micro or 0) / 1e6:g}")
# The plan above is for ONE identity shape — the endpoint's example body. Most drops are
# "this adapter takes a different identity", not "your team cannot reach this provider";
# labelling them "not available here" read as no-key and sent a reader hunting for a
# missing credential (2026-08-29). Name the shape, and give each drop its reason.
dropped_note = ""
if plan.dropped:
dropped_note = ("; for this {" + ", ".join(plan.variant) + "} example, not usable: "
+ ", ".join(f"{d['endpoint_id']} ({d['why']})" for d in plan.dropped))
return {"tier": "routed", "detail": f"routed — {len(plan.candidates)} providers callable now; first: "
f"{first.endpoint['id']} on {how} (send {{{', '.join(first.variant)}}})"
+ dropped_note,
"plan": [c.view() for c in plan.candidates], "dropped": plan.dropped}
provider = oauth_providers.get(service)
if provider is None or not provider.base_url:
return {"tier": "none", "detail": f"{service} isn't proxy-callable yet"}
# An oauth-billed provider is metered even on the org's own connection (the upstream bills
# treg's app, not the account) — the dry-run must say so, or the price is a surprise.
billed_note = ""
if provider.platform_billed and service in get_settings().oauth_billed_set:
cv = catalog_store.load().cost_view(ep.get("cost"), service) if ep.get("cost") else None
est = _platform_estimate_micro(cv, {}) if cv and cv.get("usd") else 0
billed_note = (f" — metered from the team balance (~${ledger.usd(est):g}/call: "
f"{service} bills treg's app per use)") if est else \
f" — metered from the team balance ({service} bills treg's app per use)"
probe = provider.base_url.rstrip("/") + "/" + (ep["path"] or "/").lstrip("/")
try:
target = await resolve_call_target(probe, caller, _resolve_call)
tool = target.tool
return {"tier": "tool", "metered": bool(billed_note),
"detail": f"will use this org's registered {tool.name!r} tool{billed_note}"}
except CallFailure as exc:
if exc.status_code == 403:
return {"tier": "restricted", "detail": "a registered tool exists but your access is restricted — ask an admin"}
if exc.status_code != 404:
raise _translate_call_failure(exc) from exc
if await _marketplace_secret(service, caller.org_id, db) is not None:
return {"tier": "credential", "metered": bool(billed_note),
"detail": f"will use this org's {service} credential (no tool needed){billed_note}"}
cost = _platform_offer(ep, provider, caller.org)
if cost is not None:
# The number is the honest per-call price at the DEFAULT page size — a `per_result` endpoint
# costs more or less depending on how many rows the caller asks for, so it is "~".
est = _platform_estimate_micro(cost, {})
return {
"tier": "platform",
"detail": (f"no key needed — uses treg's {service} key, ~${ledger.usd(est):g}/call "
f"from your team balance (treg balance)"),
"estimated_cost_micro": est,
"estimated_cost_usd": ledger.usd(est),
}
hint = (f"connect with: treg connections connect --provider {service}"
if not provider.uses_pasted_secret else
f"connect with: treg connections connect --provider {service}, or treg secret add {service} …")
return {"tier": "none", "detail": f"no {service} credential in this org yet — {hint}"}
@app.api_route(
"/call/{rest:path}",
+192 -35
View File
@@ -376,6 +376,17 @@ addEventListener('load', function(){
/* a switch-option card: knob + [title / caption], vertically centered on the title line */
.cliopt{display:flex;align-items:flex-start;gap:10px;margin:12px 0;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);cursor:pointer}
.cliopt .tswitch{margin-top:1px} .cliopt b{font-size:12.5px;line-height:1.4} .cliopt .sub{font-size:11px;line-height:1.5;margin:0}
/* OAuth authorization-method picker. One logical provider can have several genuinely separate
grants; make that one choice behind Add account instead of scattering secondary actions around
the page. The native radio stays visible so the selection is unambiguous and keyboard-safe. */
.method-grid{display:grid;gap:9px;margin:14px 0 18px}
.method-card{display:flex;align-items:flex-start;gap:11px;padding:13px 14px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);cursor:pointer;transition:border-color .12s,background .12s}
.method-card:hover{border-color:var(--accent)}
.method-card.on{border-color:var(--ink);box-shadow:inset 0 0 0 1px var(--ink);background:var(--bg)}
.method-card.off{opacity:.58;cursor:not-allowed}
.method-card input{margin:3px 0 0;accent-color:var(--accent);flex:none}
.method-copy{min-width:0;flex:1}.method-title{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.method-copy .sub{display:block;margin:4px 0 0;line-height:1.5}
.scrim{position:fixed;inset:0;background:rgba(20,16,10,.55);display:grid;place-items:center;z-index:50}
/* Inline spinner for waits on a live upstream (resource discovery), so a slow round-trip reads
as "working" rather than "broken". */
@@ -422,6 +433,7 @@ addEventListener('load', function(){
pre{margin:0;padding:14px;background:var(--bg);border:1px solid var(--line);border-radius:10px;font-family:var(--mono);font-size:12.5px;overflow:auto;white-space:pre-wrap;word-break:break-word;max-height:50vh}
pre.code{white-space:pre;overflow-x:auto;word-break:normal;overflow-wrap:normal;line-height:1.55}
.field{display:flex;gap:8px;margin:8px 0} .field input,.field select,.field textarea{background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:var(--rb);padding:8px 10px;font-family:var(--mono);font-size:13px} .field input,.field textarea{flex:1}
.field.auth-method-field{flex-direction:column;align-items:flex-start;gap:6px}.auth-method-field select{min-width:170px}
.login{min-height:100vh;display:grid;place-items:center} .loginbox{width:min(420px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:30px;text-align:center}
.phase2{opacity:.5} .hint{font-family:var(--mono);font-size:11px;color:var(--amber);border:1px dashed var(--amber);border-radius:6px;padding:2px 7px}
a{color:var(--accent);text-decoration:none;cursor:pointer}
@@ -1621,7 +1633,6 @@ a:hover{text-decoration-color:var(--muted)}
<div v-if="!mkProvider.configured" class="banner" style="margin-top:12px">
This server holds no client credentials for {{mkProvider.display_name}}, so the connect flow can't run here.
</div>
<div class="tgroup">
<div class="tgh">Connected accounts <span class="tgh-n">{{mkConns.length}}</span>
<span class="tgh-hint">each account gets its own tool name, so an agent can call a specific one</span></div>
@@ -1634,7 +1645,7 @@ a:hover{text-decoration-color:var(--muted)}
<td class="tn">
<b>{{c.identity_label || c.name}}</b>
<span class="sub" style="display:block;font-size:11px;overflow-wrap:anywhere" :title="c.resource_ref">
{{c.resource_name || c.resource_ref || (mkProvider.supports_discovery ? 'no '+mkProvider.resource_label+' chosen yet' : 'whole account')}}
{{c.resource_name || c.resource_ref || (c.supports_discovery ? 'no '+(c.resource_label||'account')+' chosen yet' : 'whole account')}}
</span>
</td>
<td class="th">
@@ -1643,6 +1654,7 @@ a:hover{text-decoration-color:var(--muted)}
</td>
<td class="ta">
<span v-if="c.health==='ok'" class="chip ok" title="A real upstream call succeeded with this credential">working</span>
<span v-else-if="c.health==='setup_required'" class="chip warn" :title="c.health_detail||'Account setup is required'">setup required</span>
<span v-else-if="c.health==='invalid'" class="chip warn" :title="c.last_error||'The last upstream call failed'">failing</span>
<span v-if="c.expiry_state==='expired'" class="chip warn" title="This credential has expired — reconnect">expired</span>
<span v-else-if="c.expiry_state==='expiring'" class="chip warn" :title="'Expires '+c.expires_at">expiring</span>
@@ -1653,9 +1665,9 @@ a:hover{text-decoration-color:var(--muted)}
<td class="tx" @click.stop>
<button v-if="(c.missing_capabilities||[]).length" class="btn sm" @click="startConnect(mkProvider, c)"
:title="'Ask for '+c.missing_capabilities.join(', ')+' as well'">Add {{c.missing_capabilities.join(' + ')}}</button>
<button v-if="mkProvider.supports_discovery" class="btn sm" @click="openResources(c)"
:title="'Choose which '+mkProvider.resource_label+' this account acts on'">Choose {{mkProvider.resource_label}}</button>
<button class="btn sm" @click="startConnect(mkProvider, c)" title="Re-consent to refresh this account">Reconnect</button>
<button v-if="c.supports_discovery" class="btn sm" @click="openResources(c)"
:title="'Choose which '+(c.resource_label||'account')+' this connection uses'">Choose {{c.resource_label||'account'}}</button>
<button class="btn sm" @click="reconnect(c)" title="Re-consent to refresh this account">Reconnect</button>
<button class="btn sm ico" :class="{danger:confirmDisc===c.id}" @click="disconnect(c)" :title="confirmDisc===c.id?'Click again to disconnect':'Disconnect'">✕</button>
</td>
</tr>
@@ -1671,8 +1683,9 @@ a:hover{text-decoration-color:var(--muted)}
<span v-if="mkGranted.has(cap)" class="chip ok">granted</span>
<span v-else class="mk-quiet">not requested yet</span>
</div>
<p v-if="mkCapabilityIntro(cap)" class="mk-quiet" style="margin:8px 0 0">{{mkCapabilityIntro(cap)}}</p>
<ul class="mk-perm-l">
<li v-for="d in (mkProvider.scope_detail||{})[cap]" :key="d.scope" :title="d.scope">
<li v-for="d in mkCapabilityDetails(cap)" :key="d.scope||d.label" :title="d.scope||null">
<span class="mk-tick">✓</span>{{d.label}}
</li>
</ul>
@@ -1819,7 +1832,7 @@ a:hover{text-decoration-color:var(--muted)}
<span class="lsub-price" :title="costTitle(e.cost)">{{costShort(e.cost)}}</span>
<span v-if="e.verified" class="vmark" :title="'Called for real on '+e.verified">✓</span>
<span v-else class="xmark" title="Documented, but treg has not called it with a live key yet">·</span>
<span v-if="catConnected(e.provider)" class="chip go" title="You already have a connected account that can call this"><span class="godot"></span>connected</span>
<span v-if="catEndpointConnected(e)" class="chip go" title="You have a connected account with an authorization method that can call this"><span class="godot"></span>connected</span>
<span class="lsub-path mono"><span class="cat-m">{{e.method}}</span>{{e.path}}</span>
</button>
<div class="lep" v-if="r.kind!=='merged' || epOpen[e.id]">
@@ -1878,9 +1891,9 @@ a:hover{text-decoration-color:var(--muted)}
— is open, and is the part worth reading before you sign up. -->
<button class="btn sm" :class="{primary:!mkOauth(e.provider)}" @click.stop="publicCatalog ? openSignin() : openEpTry(e)"
:title="publicCatalog ? 'Create a free team to call this — $1.00 of credit, no card' : 'Call it now, or copy the agent / CLI / API way to run it'">▶ Try it</button>
<span v-if="catConnected(e.provider)" class="chip go" title="You already have a connected account that can call this"><span class="godot"></span>Connected</span>
<span v-if="catEndpointConnected(e)" class="chip go" title="You have a connected account with an authorization method that can call this"><span class="godot"></span>Connected</span>
<button v-else-if="mkOauth(e.provider)" class="btn sm primary" @click.stop="publicCatalog ? openSignin() : openProvider(e.provider)"
:title="'Connect '+(e.provider_display||e.provider)+' — one-click OAuth, calls act as your account'">Connect {{e.provider_display||e.provider}}</button>
:title="endpointConnectLabel(e)+' — calls act as your account'">{{endpointConnectLabel(e)}}</button>
<!-- Lands on the Catalog's Platform tab focused on this provider — the
whole key shelf in view, not a dead-end detail page — so the user
sees where their key lives among the rest before they paste it. -->
@@ -3343,7 +3356,7 @@ product, including per-customer usage tracking and billing.</pre>
${{capAsk.provider.billed_rates.write_with_link_usd}} when the post links out).
Connecting your own developer app instead is never metered.</p>
<div class="ttable-wrap"><table class="ttable">
<tr v-for="cap in capAsk.provider.capabilities" :key="cap">
<tr v-for="cap in capOptions(capAsk)" :key="cap">
<!-- Deliberately NOT .tn: that class is nowrap, which is right for a name column
but here the help text shares the cell. Unwrapped, a long capability
description widens the table past the modal and .ttable-wrap's overflow:hidden
@@ -3357,6 +3370,34 @@ product, including per-customer usage tracking and billing.</pre>
</div>
</div>
<div v-if="methodAsk" class="scrim" role="dialog" aria-modal="true" aria-labelledby="method-ask-title" @click.self="methodAsk=null">
<div class="modal" style="padding:16px;width:min(560px,94vw)">
<h3 id="method-ask-title" style="margin:0 0 6px">Connect {{methodAsk.provider.display_name}}</h3>
<p class="sub" style="margin:0">Choose how this account should connect. You can add the other method separately later.</p>
<div class="method-grid" role="radiogroup" :aria-label="'How to connect '+methodAsk.provider.display_name">
<label v-for="method in methodAsk.provider.authorization_methods" :key="method.name"
:class="['method-card',{on:methodAsk.selected===method.name,off:!method.configured}]">
<input type="radio" name="authorization-method" :value="method.name" v-model="methodAsk.selected"
:disabled="!method.configured"/>
<span class="method-copy">
<span class="method-title">
<b>{{method.display_name}}</b>
<span v-if="isRecommendedMethod(methodAsk.provider,method)" class="chip ok">Recommended</span>
<span v-if="!method.configured" class="chip warn">Not configured</span>
</span>
<span class="sub">{{method.description}}</span>
<span v-if="method.consent_notice && methodAsk.selected===method.name" class="sub">{{method.consent_notice}}</span>
</span>
</label>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="btn sm" @click="methodAsk=null">Cancel</button>
<button class="btn sm primary" :disabled="connBusy || !selectedMethod(methodAsk) || !selectedMethod(methodAsk).configured"
@click="continueMethod()">Continue</button>
</div>
</div>
</div>
<div v-if="resPick" class="scrim" role="dialog" aria-modal="true" @click.self="resPick=null">
<div class="modal" style="padding:16px">
<h3 style="margin:0 0 10px">Choose {{article(resPick.label)}} {{resPick.label}}</h3>
@@ -3802,13 +3843,20 @@ product, including per-customer usage tracking and billing.</pre>
<div class="scrim" role="dialog" aria-modal="true" v-if="epTry" @click.self="epTry=null" style="place-items:stretch;justify-items:end">
<div class="drawer"><div class="hd" style="padding:15px 18px;border-bottom:1px solid var(--line)"><b>Try “{{epTry.id}}”</b><button class="btn sm" @click="epTry=null" aria-label="Close">✕</button></div>
<div class="bd" style="padding:16px 18px;overflow:auto">
<p class="explain"><span class="mono">{{epTry.method||'GET'}} {{epTry.path}}</span><br>{{epTry.summary}}</p>
<p class="explain"><span class="mono">{{epTry.method||'GET'}} {{epTryDisplayPath}}</span><br>{{epTry.summary}}</p>
<p class="sub" v-if="epTryAccess" style="margin:0 0 12px">
<template v-if="epTryAccess.tier==='platform'">⚡ {{epTryAccess.detail||'No key needed — served on treg\'s key, billed to the team balance.'}}</template>
<template v-else-if="epTryAccess.tier==='tool'||epTryAccess.tier==='credential'">🔑 Served with your team's own {{epTry.provider_display||epTry.provider}} credential — billed by the provider, not the team balance.</template>
<template v-else>{{epTryAccess.detail||'Not callable from this team yet.'}}</template>
</p>
<div v-if="epTryShowAuthSelector" class="field auth-method-field" style="max-width:520px;margin-bottom:16px">
<label>Authorization</label>
<select v-model="epTryAuthMethod" @change="loadEpTryAccess">
<option v-for="m in epTryAuthMethods" :key="m" :value="m">{{authorizationMethodLabel(epTry.provider,m)}}</option>
</select>
</div>
<div class="seg" style="margin-bottom:16px">
<button :class="{on:epTryTab==='agent'}" @click="epTryTab='agent'">AI Agent</button>
<button :class="{on:epTryTab==='cli'}" @click="epTryTab='cli'">CLI</button>
@@ -3850,18 +3898,18 @@ treg login</pre></div>
dead Run button — point them at the tab that DOES work. -->
<div v-if="epTryAccess && epTryAccess.tier!=='platform' && epTryAccess.tier!=='tool' && epTryAccess.tier!=='credential'"
class="banner" style="margin:0">
Can't run this here yet — {{mkOauth(epTry.provider) ? 'connect '+(epTry.provider_display||epTry.provider)+' first, then Run.' : 'this endpoint needs a key. Use the AI Agent / CLI / API tab, or bring your own.'}}
{{epTryAccess.missing_message || (mkOauth(epTry.provider) ? 'Can\'t run this here yet — connect '+(epTry.provider_display||epTry.provider)+' first, then Run.' : 'This endpoint needs a key. Use the AI Agent / CLI / API tab, or bring your own.')}}
<div style="margin-top:10px">
<button v-if="mkOauth(epTry.provider)" class="btn sm primary" @click="openProvider(epTry.provider); epTry=null">Connect {{epTry.provider_display||epTry.provider}}</button>
<button v-if="mkOauth(epTry.provider)" class="btn sm primary" @click="openProvider(epTry.provider); epTry=null">{{epTryAccess.action_label||endpointConnectLabel(epTry)}}</button>
<button v-else-if="mkKnown(epTry.provider)" class="btn sm primary" @click="goByok(epTry.provider)">🔑 Bring your own key</button>
</div>
</div>
<template v-else>
<div class="field" v-for="p in epTryParams" :key="p.name" style="max-width:520px">
<div class="field" v-for="p in epTryVisibleParams" :key="p.name" style="max-width:520px">
<label class="mono" style="min-width:140px">{{p.name}}<span v-if="p.required" style="color:var(--accent)"> *</span></label>
<input v-model="p.value" :placeholder="p.required?'required':'optional'"/>
</div>
<p v-if="!epTryParams.length && (epTry.method||'GET')==='GET'" class="sub" style="margin:0 0 12px">No parameters — just run it.</p>
<p v-if="!epTryVisibleParams.length && (epTry.method||'GET')==='GET'" class="sub" style="margin:0 0 12px">No parameters — just run it.</p>
<div v-if="(epTry.method||'GET')!=='GET' && epTryBody!==''" class="field" style="max-width:520px;align-items:flex-start">
<label style="min-width:140px">Body (JSON)</label>
<textarea v-model="epTryBody" rows="6" style="width:100%;font-family:var(--mono);font-size:12px"></textarea>
@@ -4005,13 +4053,14 @@ createApp({
epTab:{}, // endpoint id → which pane of its detail is showing ('req' | 'res')
platEx:{}, // endpoint id → {open, loading, err, text} for the lazily-fetched example
platCopied:'', // endpoint id whose `treg call` line was just copied
capAsk:null, // the access question, asked when Connect is clicked
capAsk:null, // the access question, asked when a one-method provider has several scope levels
methodAsk:null, // separate grants behind one provider: one selected method, then Continue
tokenAsk:null, // bring-your-own-bot setup (Slack): a form, not a redirect
extraCred:{}, extraBusy:null, // second credential a provider needs on top of OAuth (Google Ads' developer token)
cfgMenu:false, // detail-page ⚙ Configure tool-picker (skills with >1 tool)
tryMenu:false, // detail-page ▶ Try it tool-picker (skills with >1 tool)
// marketplace endpoint Try-it drawer
epTry:null, epTryTab:'agent', epTryAccess:null, epTryParams:[], epTryBody:'', epTryResp:'', epTryStatus:null,
epTry:null, epTryTab:'agent', epTryAccess:null, epTryAccessByMethod:{}, epTryAuthMethod:'', epTryParams:[], epTryBody:'', epTryResp:'', epTryStatus:null,
epTryMs:null, epTryCost:null, epTryBusy:false,
catalogClis:null, // bin → catalog default deny patterns (lazy, from /providers.json)
newSkill:false, skillJson:'', skillBusy:false, skillErr:'', skillMode:'folder',
@@ -4158,13 +4207,13 @@ createApp({
platRow(){ return this.plats.list.find(pl=>pl.slug===this.platSlug)||null; },
platLabel(){ return (this.platData&&this.platData.platform&&this.platData.platform.label)
|| (this.platRow&&this.platRow.label) || this.platSlug || 'Platform'; },
platProviders(){ // providers with endpoints here, in catalog order; only ones treg can connect
platProviders(){ // providers with endpoints here, in catalog order
const seen=[]; for(const g of (this.platData&&this.platData.capabilities||[])) for(const e of (g.endpoints||[])) if(!seen.includes(e.provider)) seen.push(e.provider);
for(const e of (this.platData&&this.platData.extended||[])) if(!seen.includes(e.provider)) seen.push(e.provider);
// The filter keeps the row to providers treg can actually connect — a member-only fact, read
// from /connections. A public visitor has no session, so naming every provider that supplies
// this shelf is both all we can do and the thing that page is there to say.
return this.publicCatalog ? seen : seen.filter(s=>this.providers.some(p=>p.service===s)); },
// Navigation is a fact of THIS platform response, not of the separately-loaded connection
// registry. Depending on that async registry made the entire row flicker away locally. `treg`
// is the synthetic router, not a provider page a person can open.
return seen.filter(s=>s!=='treg'); },
// ---- the ledger ----
// Sections, their order and the merged/single split are all decided by the server (see
// catalog_store.domain_rows) so the CLI, the API and this page can't disagree about what the
@@ -4216,7 +4265,7 @@ createApp({
? 'The cheapest of the '+eps.length+' providers on this row — open it for each one'
: this.costTitle(eps[0].cost),
verified: eps.some(e=>!!e.verified),
ready: eps.some(e=>this.catConnected(e.provider)),
ready: eps.some(e=>this.catEndpointConnected(e)),
hay: (r.description+' '+r.domain+' '+(r.capability||'')+' '+eps.map(e=>
e.provider+' '+(e.provider_display||'')+' '+e.path+' '+e.summary).join(' ')).toLowerCase()});
}
@@ -4399,15 +4448,25 @@ createApp({
welcomeSetupMasked(){ const t=this.myToken?(this.startTokenShow?this.myToken:(this.myToken.slice(0,14)+'••••••••••••••••')):'<YOUR_TOKEN>';
return this.welcomeSetupCmd+'\n\nwith team '+(this.activeSlugNow||'<team-slug>')+' token: '+t; },
// Try-it drawer: the filled query string + the three "how to run it" recipes (agent / CLI / API)
epTryQuery(){ return (this.epTryParams||[]).filter(p=>p.value!=='' && p.value!=null)
epTryAuthMethods(){ if(!this.epTry) return [];
return [...new Set([this.epTry.authorization_method, ...(this.epTry.authorization_methods||[]),
...Object.keys(this.epTry.authorization_paths||{})].filter(Boolean))]; },
epTryConnectedMethods(){ return this.epTryAuthMethods.filter(m=>{
const a=this.epTryAccessByMethod[m]; return a && (a.tier==='tool'||a.tier==='credential'); }); },
epTryShowAuthSelector(){ return this.epTryAuthMethods.length>1 && this.epTryConnectedMethods.length>1; },
epTryDisplayPath(){ if(!this.epTry) return '';
return (this.epTry.authorization_paths||{})[this.epTryAuthMethod]||this.epTry.path; },
epTryVisibleParams(){ return (this.epTryParams||[]).filter(p=>this.epTryParamAllowed(p)); },
epTryQuery(){ return this.epTryVisibleParams.filter(p=>p.value!=='' && p.value!=null)
.map(p=>encodeURIComponent(p.name)+'='+encodeURIComponent(p.value)).join('&'); },
epTryShellBody(){ return "'"+String(this.epTryBody).replace(/'/g,"'\"'\"'")+"'"; },
epTryCliCall(){ if(!this.epTry) return '';
const args=(this.epTryParams||[]).filter(p=>p.value!=='' && p.value!=null)
const args=this.epTryVisibleParams.filter(p=>p.value!=='' && p.value!=null)
.map(p=>`--query ${p.name}=${/\s/.test(String(p.value))?JSON.stringify(String(p.value)):p.value}`).join(' ');
const method=(this.epTry.method||'GET').toUpperCase();
let s=`treg call ${this.epTry.id}${args?' '+args:''}`;
if(method!=='GET') s+=` --method ${method}`;
if(this.epTryAuthMethod) s+=` --authorization-method ${this.epTryAuthMethod}`;
if(method!=='GET' && this.epTryBody.trim()) s+=` --data ${this.epTryShellBody}`;
return s; },
epTryCurl(){ if(!this.epTry) return '';
@@ -4415,6 +4474,7 @@ createApp({
const tok=this.myToken||'$TREG_TOKEN', method=(this.epTry.method||'GET').toUpperCase();
let s=`curl -X ${method} "${url}" \\\n -H "X-Treg-Token: ${tok}"`;
if(this.sessionMode && this.activeSlugNow) s+=` \\\n -H "X-Treg-Org: ${this.activeSlugNow}"`; // minted identity token needs the org header
if(this.epTryAuthMethod) s+=` \\\n -H "X-Treg-Authorization-Method: ${this.epTryAuthMethod}"`;
if(method!=='GET' && this.epTryBody.trim()) s+=` \\\n -H "Content-Type: application/json" \\\n --data ${this.epTryShellBody}`;
return s; },
// token + team embedded HERE ONLY (a copy-and-run-now context) — the setup line elsewhere stays clean
@@ -4422,7 +4482,8 @@ createApp({
return `set up treg — ${this.proxy}/llms.txt with team ${S} token: ${T}`; },
epTryAgentUse(){ if(!this.epTry) return '';
const what=this.epTry.summary ? this.epTry.summary.replace(/\.$/,'') : this.epTry.id;
return `Use treg to call ${this.epTry.id} — ${what}.`; },
const auth=this.epTryAuthMethod ? ` Use authorization_method=${this.epTryAuthMethod}.` : '';
return `Use treg to call ${this.epTry.id} — ${what}.${auth}`; },
tryExamples(){ return [
{k:'trend',cat:'Trending videos pattern', logo:'tiktok', prompt:'Use treg to pull today\'s trending TikTok videos (video links included)'},
{k:'enr', cat:'Get contact emails', logo:'people', avatar:'https://pbs.twimg.com/profile_images/1131851609774985216/OcsssQ9J_400x400.png', prompt:'Use treg to find the work email of Peter Steinberger'},
@@ -5506,6 +5567,14 @@ createApp({
}catch(e){ this.connErr=String(e.message||e); }
this.loadPlatforms(); // fire-and-forget: the catalog must never hold up the connect UI
},
authorizationMethodSpec(providerName, methodName){
const provider=(this.providers||[]).find(item=>item.service===providerName);
return provider && (provider.authorization_methods||[]).find(method=>method.name===methodName);
},
authorizationMethodLabel(providerName, methodName){
const method=this.authorizationMethodSpec(providerName,methodName);
return (method&&method.display_name)||methodName;
},
async connectProvider(p, capability, conn){
// Consent happens in a popup so the dashboard keeps its state; we poll for the result
// rather than depending on the popup being able to talk back to us.
@@ -5526,7 +5595,7 @@ createApp({
// provider has something to choose between, ask straight away rather than leaving the
// user staring at a row that looks finished but isn't.
const fresh=(this.connections||[]).find(c=>c.id===s.secret_id);
if(fresh && p.supports_discovery && !fresh.resource_ref) await this.openResources(fresh);
if(fresh && fresh.supports_discovery && !fresh.resource_ref) await this.openResources(fresh);
return;
}
if(s.status==='error'){ this.connErr='Connect failed: '+(s.detail||'unknown'); this.connBusy=false; return; }
@@ -5548,9 +5617,27 @@ createApp({
// credential, so nothing downstream has to be rewired.
const p=this.providers.find(x=>x.service===c.provider);
if(!p){ this.connErr='Unknown provider for this connection: '+c.provider; return; }
await this.connectProvider(p, (p.capabilities||['read'])[0]);
const method=(p.authorization_methods||[]).find(m=>m.name===c.authorization_method);
const options=method ? method.capabilities : (c.capabilities||p.capabilities||['read']);
const cap=(c.capabilities||[]).slice(-1)[0] || options.slice(-1)[0] || p.default_capability;
await this.connectProvider(p, cap, c);
},
capOptions(ask){
// Authorization-method capabilities are declared narrowest → broadest for scope
// containment. Present the choice broadest → narrowest, matching every existing provider.
if(ask && ask.method) return [...(ask.method.capabilities||[])].reverse();
if(!ask || !ask.conn) return (ask&&ask.provider.capabilities)||[];
const method=(ask.provider.authorization_methods||[]).find(m=>m.name===ask.conn.authorization_method);
return method ? method.capabilities : (ask.provider.capabilities||[]);
},
article(w){ return /^[aeiou]/i.test(w||'') ? 'an' : 'a'; }, // "an account", not "a account"
mkCapabilityMethod(cap){ return ((this.mkProvider&&this.mkProvider.authorization_methods)||[]).find(m=>
m.capability_details && (m.capability_details[cap]||[]).length); },
mkCapabilityIntro(cap){ const m=this.mkCapabilityMethod(cap); return (m&&m.capability_intros&&m.capability_intros[cap])||''; },
mkCapabilityDetails(cap){
const m=this.mkCapabilityMethod(cap), labels=m&&m.capability_details[cap];
return labels ? labels.map(label=>({scope:'',label})) : (((this.mkProvider&&this.mkProvider.scope_detail)||{})[cap]||[]);
},
capLabel(cap){ return {read:'Read only', draft:'Read and draft', post:'Read and publish', write:'Read and write', manage:'Full access'}[cap] || cap; },
capHelp(cap){ return {
read:'Your agent can view data. It can never change anything.',
@@ -5565,14 +5652,36 @@ createApp({
write:'Your agent can view data and also create, update or publish.',
manage:'Your agent can view and manage everything in the account.',
}[cap] || 'Requests the '+cap+' scopes.'; },
isRecommendedMethod(p, method){ return !!(method && (method.capabilities||[]).includes(p.default_capability)); },
selectedMethod(ask){ return ask && (ask.provider.authorization_methods||[]).find(m=>m.name===ask.selected); },
methodCapability(p, method){
const caps=(method&&method.capabilities)||[];
return caps.includes(p.default_capability) ? p.default_capability : (caps[caps.length-1]||p.default_capability);
},
startConnect(p, conn){
// A pasted-secret provider has no consent screen — the user brings their own bot token (Slack)
// or API key (Apollo, TikHub, …), so setup is a form, not a redirect.
if(p.auth_kind==='token' || p.auth_kind==='key') return void (this.tokenAsk={provider:p, token:'', err:'', busy:false, conn});
// Several separate grants are one Add-account decision. Providers with zero or one method
// keep the old one-click behavior, so LinkedIn and every existing single-method flow do not
// inherit an extra dialog. Reconnects also stay pinned to their stored method.
const methods=p.authorization_methods||[];
if(methods.length>1 && !conn){
const available=methods.filter(method=>method.configured);
const recommended=available.find(m=>this.isRecommendedMethod(p,m)) || available[0] || methods[0];
this.methodAsk={provider:p,selected:recommended.name}; return;
}
if(methods.length===1 && !conn) return this.connectProvider(p,this.methodCapability(p,methods[0]));
// One capability means there's nothing to ask — don't put a dialog in the way.
if((p.capabilities||[]).length<2) return this.connectProvider(p, p.default_capability, conn);
this.capAsk={provider:p, conn};
},
async continueMethod(){
const ask=this.methodAsk, method=this.selectedMethod(ask); if(!method || !method.configured) return;
this.methodAsk=null;
if((method.capabilities||[]).length>1){ this.capAsk={provider:ask.provider,conn:null,method}; return; }
await this.connectProvider(ask.provider,this.methodCapability(ask.provider,method));
},
async submitToken(){
const t=this.tokenAsk; if(!t.token.trim()) return;
t.busy=true; t.err='';
@@ -5607,7 +5716,8 @@ createApp({
const d=await this.api('/connections/'+c.id+'/resources');
if(!this.resPick || this.resPick.id!==c.id) return; // user closed it or opened another
this.resPick={id:c.id, label:d.resource_label||label, plural:d.resource_plural||plural,
rows:d.resources||[], selected:d.selected||'', loading:false, err:''};
rows:d.resources||[], selected:d.selected||'', loading:false,
err:d.setup_required?(d.setup_detail||'Account setup is required.'):''};
// Discovery can change the row underneath us — it backfills a missing label and records
// that the credential works — so pull the list again rather than leaving stale text on screen.
this.loadConnections();
@@ -5817,6 +5927,22 @@ createApp({
p.native ? 'billed as '+p.native+' / '+this.priceUnit(pf.type)+', converted at the catalog’s FX rate' : '',
pf.note].filter(Boolean).join(' — '); },
catConnected(service){ return !!this.connCount[service]; },
endpointAuthMethods(e){ return [...new Set([e&&e.authorization_method,
...((e&&e.authorization_methods)||[]), ...Object.keys((e&&e.authorization_paths)||{})].filter(Boolean))]; },
endpointMethodSpec(e){
const methods=this.endpointAuthMethods(e); if(methods.length!==1) return null;
const p=this.providers.find(x=>x.service===e.provider);
return p && (p.authorization_methods||[]).find(m=>m.name===methods[0]);
},
catEndpointConnected(e){
const methods=this.endpointAuthMethods(e);
if(!methods.length) return this.catConnected(e.provider);
return (this.connections||[]).some(c=>c.provider===e.provider && methods.includes(c.authorization_method));
},
endpointConnectLabel(e){
const method=this.endpointMethodSpec(e);
return (method&&method.action_label)||('Connect '+(e.provider_display||e.provider));
},
// Whether a REGISTRY connection to this provider is still metered. Connecting usually ends the
// billing question — the account you connect is the licence — but a `metered` provider bills
// treg's own app per use (X since Feb 2026), so those calls are debited from the team balance
@@ -5855,7 +5981,7 @@ createApp({
return typeof c.usd==='number' ? c.usd : null; },
// An OAuth endpoint you have already connected costs nothing MORE to call — the account is the
// licence — so a connected own_account row is the free path, and usually the right answer.
capFree(e){ return this.catConnected(e.provider) && !this.catMetered(e.provider)
capFree(e){ return this.catEndpointConnected(e) && !this.catMetered(e.provider)
&& (e.scope==='own_account' || (e.cost&&e.cost.type==='free')); },
capCheapest(eps){
let best=null;
@@ -6179,13 +6305,14 @@ createApp({
// ---- marketplace endpoint Try-it ----
openEpTry(e){
this.epTry=e; this.epTryTab='agent'; this.epTryResp=''; this.epTryStatus=null; this.epTryMs=null; this.epTryCost=null;
this.epTryAccess=null; this.epTryBusy=false;
this.epTryAccess=null; this.epTryAccessByMethod={}; this.epTryBusy=false;
this.epTryAuthMethod=e.authorization_method||((e.authorization_methods||[])[0])||'';
const secs=this.paramSections(e);
const tr=e.test_request||{}; // the live-verified request — the best possible prefill
const trq={...(tr.queryParams||{}), ...(tr.pathParams||{})};
const qp=[]; // query + path params (path placeholders are consumed from query server-side)
for(const s of secs) if(s.key!=='body') for(const p of s.rows)
qp.push({name:p.name, required:!!p.required,
qp.push({name:p.name, required:!!p.required, authorization_methods:p.authorization_methods||[],
value: trq[p.name]!=null ? this.fmtExample(trq[p.name])
: (p.example!=null ? this.fmtExample(p.example) : '')});
// test_request may carry params the input spec doesn't list — keep them, they made the call work
@@ -6202,13 +6329,43 @@ createApp({
}
this.epTryBody=body;
// the authenticated dry-run: which ladder rung would serve this org, and at what price
this.api('/catalog/endpoints/'+e.id+'/access').then(a=>this.epTryAccess=a).catch(()=>{});
this.loadEpTryAccessPolicy();
},
epTryParamAllowed(p){ return !p.authorization_methods || !p.authorization_methods.length
|| p.authorization_methods.includes(this.epTryAuthMethod); },
async loadEpTryAccessPolicy(){
const e=this.epTry; if(!e) return;
const methods=this.epTryAuthMethods;
if(!methods.length){ this.loadEpTryAccess(); return; }
const rows=await Promise.all(methods.map(async m=>{
try{ return [m, await this.api('/catalog/endpoints/'+e.id+'/access?authorization_method='+encodeURIComponent(m))]; }
catch(_){ return [m, null]; }
}));
if(this.epTry!==e) return;
this.epTryAccessByMethod=Object.fromEntries(rows);
const connected=this.epTryConnectedMethods;
if(connected.length===1) this.epTryAuthMethod=connected[0];
else this.epTryAuthMethod=e.authorization_method||methods[0]||'';
this.epTryAccess=this.epTryAccessByMethod[this.epTryAuthMethod]||null;
},
loadEpTryAccess(){
if(!this.epTry) return;
if(this.epTryAccessByMethod[this.epTryAuthMethod]){
this.epTryAccess=this.epTryAccessByMethod[this.epTryAuthMethod]; return;
}
const q=this.epTryAuthMethod?'?authorization_method='+encodeURIComponent(this.epTryAuthMethod):'';
this.epTryAccess=null;
this.api('/catalog/endpoints/'+this.epTry.id+'/access'+q).then(a=>{
this.epTryAccess=a;
if(this.epTryAuthMethod) this.epTryAccessByMethod={...this.epTryAccessByMethod,[this.epTryAuthMethod]:a};
}).catch(()=>{});
},
async runEpTry(){
const e=this.epTry; if(!e) return; this.epTryBusy=true; const t0=performance.now();
try{
const qs=this.epTryParams.filter(p=>String(p.value)!=='').map(p=>encodeURIComponent(p.name)+'='+encodeURIComponent(p.value)).join('&');
const qs=this.epTryVisibleParams.filter(p=>String(p.value)!=='').map(p=>encodeURIComponent(p.name)+'='+encodeURIComponent(p.value)).join('&');
const opts={method:e.method||'GET', credentials:'include', headers:{...this.headers()}};
if(this.epTryAuthMethod) opts.headers['X-Treg-Authorization-Method']=this.epTryAuthMethod;
if(opts.method!=='GET' && this.epTryBody.trim()){ opts.body=this.epTryBody; opts.headers['content-type']='application/json'; }
const r=await fetch('/call/'+e.id+(qs?'?'+qs:''), opts);
this.epTryStatus=r.status; this.epTryMs=Math.round(performance.now()-t0);
@@ -6250,7 +6407,7 @@ createApp({
this.orgMenuStyle={position:'fixed', top:(r.bottom+6)+'px', left:r.left+'px'};
},
toggleOrgMenu(){ this.orgMenu=!this.orgMenu; if(this.orgMenu) this.placeOrgMenu(); },
closeOverlays(){ this.newTool=false; this.newSkill=false; this.showJoin=false; this.newOrg=false; this.addOrg=false; this.tryTool=null; this.copyTool=null; this.orgMenu=false; this.share.on=false; },
closeOverlays(){ this.newTool=false; this.newSkill=false; this.showJoin=false; this.newOrg=false; this.addOrg=false; this.tryTool=null; this.copyTool=null; this.orgMenu=false; this.share.on=false; this.methodAsk=null; },
},
async mounted(){
document.documentElement.dataset.theme=this.theme;
+25
View File
@@ -43,6 +43,7 @@ for _k in (
"X_CLIENT_ID", "X_CLIENT_SECRET", "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET",
"TIKTOK_CLIENT_KEY", "TIKTOK_CLIENT_SECRET",
"META_CLIENT_ID", "META_CLIENT_SECRET",
"INSTAGRAM_CLIENT_ID", "INSTAGRAM_CLIENT_SECRET",
# …and the tier-4 platform keys + their allow-list. A developer's .env carries real, FUNDED keys:
# without this a suite run on their laptop could resolve tier 4 and spend actual money on the
# in-process upstream's echo. Tests that exercise tier 4 set both halves via monkeypatch.
@@ -93,7 +94,22 @@ def make_upstream(hook_hits: list | None = None) -> FastAPI:
# graph.facebook.com path must exist for a registry-mode Meta connect to complete.
return {"access_token": "META-TOKEN", "token_type": "bearer", "expires_in": 5183944}
@up.post("/oauth/access_token")
async def instagram_short_token() -> dict:
return {"access_token": "IG-SHORT-TOKEN", "user_id": 17841400000000000}
@up.get("/access_token")
async def instagram_long_token() -> dict:
return {"access_token": "IG-LONG-TOKEN", "token_type": "bearer", "expires_in": 5184000}
@up.get("/v25.0/me")
async def instagram_identity(request: Request):
if request.headers.get("authorization") != "Bearer IG-LONG-TOKEN":
return JSONResponse({"error": {"message": "invalid token"}}, status_code=401)
return {"user_id": "17841400000000000", "username": "direct_ig"}
@up.get("/me/accounts")
@up.get("/v25.0/me/accounts")
async def meta_pages(request: Request) -> dict:
# Meta's primary Page listing: what the user manages through a PERSONAL Page role. One row
# carries both the facebook shape (id/name) and the instagram shape (nested professional
@@ -107,6 +123,7 @@ def make_upstream(hook_hits: list | None = None) -> FastAPI:
return {"data": [page]}
@up.get("/me/businesses")
@up.get("/v25.0/me/businesses")
async def meta_businesses(request: Request):
# Meta's Business walk (needs business_management): each business row nests owned_pages /
# client_pages whose entries are shaped like /me/accounts rows. PAGE-DIRECT reappears here
@@ -142,6 +159,12 @@ def make_upstream(hook_hits: list | None = None) -> FastAPI:
@up.get("/v25.0/{page_id}/conversations")
async def instagram_conversations(page_id: str, request: Request):
if request.headers.get("authorization") == "Bearer IG-LONG-TOKEN":
return {
"auth": request.headers["authorization"],
"raw_path": request.scope.get("raw_path", b"").decode(),
"data": [{"id": "IG-DIRECT-CONVERSATION-1", "ig_user_id": page_id}],
}
# Messaging is the regression this Meta token split fixes: the user token is valid OAuth,
# but this edge accepts only the token of the Page linked to the selected Instagram account.
if request.headers.get("authorization") != "Bearer PAGE-TOKEN-DIRECT":
@@ -149,6 +172,8 @@ def make_upstream(hook_hits: list | None = None) -> FastAPI:
{"error": {"message": "must be called with a Page access token", "code": 190}},
status_code=403,
)
if request.query_params.get("platform") != "instagram":
return {"data": []}
return {"data": [{"id": "IG-CONVERSATION-1", "page_id": page_id}]}
@up.post("/v25.0/{page_id}/subscribed_apps")
+13
View File
@@ -182,6 +182,19 @@ def test_call_runtime_import_edges_point_inward() -> None:
assert _package_forbidden_imports(_SRC / "infra" / "upstream", upstream_forbidden) == set()
def test_catalog_access_router_only_translates_the_application_result() -> None:
tree = ast.parse((_SRC / "routers" / "call.py").read_text())
owner = next(
node for node in tree.body
if isinstance(node, ast.AsyncFunctionDef) and node.name == "catalog_endpoint_access"
)
body = "\n".join(ast.unparse(statement) for statement in owner.body)
assert _call_names(body) == {
"get_catalog_endpoint_access",
"_translate_call_failure",
}
@pytest.mark.parametrize(
("package", "forbidden", "mutation"),
[
+76 -11
View File
@@ -355,8 +355,10 @@ async def test_instagram_message_send_is_try_ready_but_never_auto_verified(clien
r = await clients.get("/catalog/endpoints/instagram.instagram.message.send")
assert r.status_code == 200, r.text
ep = r.json()["endpoint"]
assert ep["method"] == "POST" and ep["path"] == "/{page_id}/messages"
assert set(ep["input"]["pathParams"]) == {"page_id"}
assert ep["method"] == "POST" and ep["path"] == "/{ig_user_id}/messages"
assert ep["authorization_methods"] == ["instagram-login", "facebook-page"]
assert set(ep["input"]["pathParams"]) == {"ig_user_id", "page_id"}
assert ep["authorization_paths"]["facebook-page"].startswith("/{page_id}/messages")
assert ep["input"]["bodyType"] == "json"
assert set(ep["input"]["body"]) == {"recipient", "message"}
assert "IGSID" in ep["input"]["body"]["recipient"]["note"]
@@ -364,18 +366,81 @@ async def test_instagram_message_send_is_try_ready_but_never_auto_verified(clien
assert loaded["verified"] is None and not loaded["test_request"]
async def test_instagram_conversations_use_the_linked_facebook_page_id(clients: AsyncClient):
"""Facebook-login Instagram inbox sync is a Page conversation edge filtered to Instagram.
The Instagram account id identifies the profile but Meta rejects it on this graph.facebook.com
conversations route with error #3, even when the caller correctly holds the linked Page token.
"""
async def test_instagram_conversations_keep_direct_and_page_routes(clients: AsyncClient):
r = await clients.get("/catalog/endpoints/instagram.x.user-messages")
assert r.status_code == 200, r.text
ep = r.json()["endpoint"]
assert ep["path"] == "/{page_id}/conversations"
assert set(ep["input"]["pathParams"]) == {"page_id"}
assert "Instagram account id returns Meta error (#3)" in ep["input"]["note"]
assert ep["path"] == "/{ig_user_id}/conversations"
assert set(ep["input"]["pathParams"]) == {"ig_user_id", "page_id"}
assert ep["authorization_paths"]["facebook-page"].startswith("/{page_id}/conversations")
assert "?" not in ep["authorization_paths"]["facebook-page"]
assert ep["input"]["queryParams"]["platform"]["required"] is True
assert ep["input"]["queryParams"]["platform"]["authorization_methods"] == ["facebook-page"]
assert "Existing Facebook-backed connections" in ep["input"]["note"]
assert ep["input"]["pathParams"]["ig_user_id"]["authorization_methods"] == ["instagram-login"]
assert ep["input"]["pathParams"]["page_id"]["authorization_methods"] == ["facebook-page"]
assert "--authorization-method" not in ep["call_template"]
assert "page_id=" not in ep["call_template"]
page_only = cs.load().by_id["instagram.x.hashtag-search"]
assert "--authorization-method facebook-page" in cs.call_template(page_only)
def test_every_instagram_path_placeholder_has_a_declared_try_input():
"""The UI, CLI and agents all learn path values from `input.pathParams`; runtime parsing the
braces independently is only the last guard, not a usable endpoint contract."""
endpoints = [ep for ep in cs.load().by_id.values() if ep["provider"] == "instagram"]
assert len(endpoints) == 32
for ep in endpoints:
paths = [ep.get("path") or "", *(ep.get("authorization_paths") or {}).values()]
placeholders = {name for path in paths for name in re.findall(r"{([A-Za-z0-9_]+)}", path)}
declared = set((((ep.get("input") or {}).get("pathParams")) or {}))
assert placeholders <= declared, ep["id"]
extended = [ep for ep in endpoints if ep["tier"] == "extended"]
assert len(extended) == 22
assert all(ep.get("input") for ep in extended)
def test_instagram_extended_actions_declare_every_required_write_value():
cat = cs.load()
expected = {
"instagram.x.media-comment-create": ({"ig_media_id"}, {"message"}),
"instagram.x.comment-reply-create": ({"ig_comment_id"}, {"message"}),
"instagram.x.comment-hide": ({"ig_comment_id"}, {"hide"}),
"instagram.x.comment-delete": ({"ig_comment_id"}, set()),
}
for endpoint_id, (path_names, query_names) in expected.items():
inp = cat.by_id[endpoint_id]["input"]
assert set(inp.get("pathParams") or {}) == path_names
assert set(inp.get("queryParams") or {}) == query_names
assert all(spec.get("required") for spec in (inp.get("pathParams") or {}).values())
assert all(spec.get("required") for spec in (inp.get("queryParams") or {}).values())
hide = cat.by_id["instagram.x.comment-hide"]
assert hide["path"] == "/{ig_comment_id}"
assert hide["input"]["queryParams"]["hide"]["type"] == "boolean"
def test_page_only_instagram_contracts_expose_required_meta_parameters():
catalog = cs.load()
product_search = catalog.by_id["instagram.x.catalog-product-search"]
assert set(product_search["input"]["queryParams"]) >= {"catalog_id", "q"}
assert product_search["input"]["queryParams"]["catalog_id"]["required"] is True
appeal = catalog.by_id["instagram.x.user-product-appeal"]
assert appeal["input"]["queryParams"]["product_id"]["required"] is True
discovery = catalog.by_id["instagram.x.user-business-discovery"]
assert discovery["path"] == "/{ig_user_id}"
assert discovery["input"]["queryParams"]["fields"]["required"] is True
assert "business_discovery.username(" in discovery["input"]["queryParams"]["fields"]["example"]
assert "--query 'fields=business_discovery.username(" in cs.call_template(discovery)
def test_instagram_catalog_paths_do_not_embed_query_strings():
"""The relay sends caller params separately, so static query values belong in `input`."""
endpoints = [ep for ep in cs.load().by_id.values() if ep["provider"] == "instagram"]
for ep in endpoints:
assert "?" not in ep["path"], ep["id"]
assert all("?" not in path for path in (ep.get("authorization_paths") or {}).values()), ep["id"]
async def test_retired_rows_leave_discovery_but_keep_an_actionable_direct_lookup(clients: AsyncClient):
+45
View File
@@ -246,6 +246,19 @@ def test_call_preserves_duplicate_query_keys(monkeypatch):
assert params == [("tag", "a"), ("tag", "b")] # both survive; a dict would drop tag=a
def test_call_sends_explicit_authorization_method_as_treg_control_header(monkeypatch):
fake = _FakeClient()
monkeypatch.setattr(cli, "_client", lambda cfg: fake)
monkeypatch.setattr(cli, "_show", lambda r: None)
args = cli.build_parser().parse_args([
"call", "future-provider.endpoint",
"--authorization-method", "delegated-admin",
"--query", "page_id=PAGE-1",
])
cli.cmd_call(args, {"base_url": "http://x"})
assert fake.calls[0][4]["X-Treg-Authorization-Method"] == "delegated-admin"
def test_call_query_without_equals_exits_cleanly(monkeypatch):
monkeypatch.setattr(cli, "_client", lambda cfg: _FakeClient())
args = cli.build_parser().parse_args(["call", "echo", "--query", "flag"])
@@ -284,6 +297,38 @@ def test_oauth_connect_without_provider_or_client_secret_exits():
cli.cmd_oauth_connect(args, {"base_url": "http://x"})
def test_oauth_connect_prints_provider_guidance_from_the_api(monkeypatch, capsys):
class Response:
status_code = 200
def json(self):
return {
"state": "STATE",
"redirect_uri": "https://registry.example/oauth/callback",
"consent_url": "https://provider.example/authorize",
"connect_guidance": "Use the linked workspace administrator grant.",
}
class StatusResponse:
def json(self):
return {"status": "done", "secret_id": 7, "name": "future-provider"}
class Client:
def __enter__(self): return self
def __exit__(self, *args): return False
def post(self, path, json): return Response()
def get(self, path): return StatusResponse()
monkeypatch.setattr(cli, "_client", lambda cfg: Client())
monkeypatch.setattr(cli.time, "sleep", lambda seconds: None)
args = cli.build_parser().parse_args([
"connections", "connect", "--provider", "future-provider",
])
cli.cmd_oauth_connect(args, {"base_url": "http://x"})
output = capsys.readouterr().out
assert "Use the linked workspace administrator grant." in output
def test_skill_push_missing_file_exits():
args = type("A", (), {"file": "/nonexistent/skill.json"})()
with pytest.raises(SystemExit):
+28 -11
View File
@@ -229,6 +229,8 @@ async def test_successful_discovery_marks_the_connection_working(clients: AsyncC
def treg_meta_app(monkeypatch):
monkeypatch.setenv("TREG_META_CLIENT_ID", "treg-meta-cid")
monkeypatch.setenv("TREG_META_CLIENT_SECRET", "treg-meta-csec")
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_ID", "treg-instagram-cid")
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_SECRET", "treg-instagram-csec")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@@ -239,8 +241,18 @@ def _meta_test_provider(monkeypatch, service: str, **over):
from treg import oauth_providers as P
provider = P.REGISTRY[service]
if service == "instagram":
methods = tuple(
dataclasses.replace(
method,
overrides=tuple({**dict(method.overrides), **over}.items()),
) if method.name == "facebook-page" else method
for method in provider.authorization_methods
)
provider = dataclasses.replace(provider, authorization_methods=methods)
monkeypatch.setitem(P.REGISTRY, service, dataclasses.replace(
P.REGISTRY[service], discover_base_url="http://upstream", **over))
provider, discover_base_url="http://upstream", **over))
async def test_business_owned_pages_join_the_facebook_picker(clients: AsyncClient, treg_meta_app, monkeypatch):
@@ -266,7 +278,7 @@ async def test_business_owned_instagram_accounts_join_the_picker(clients: AsyncC
professional accounts, a Page without one drops out instead of surviving as an id-less
phantom row, and the directly-reachable account is not doubled."""
_meta_test_provider(monkeypatch, "instagram")
st = await _connect_byo(clients, provider="instagram", name="instagram")
st = await _connect_byo(clients, provider="instagram", capability="page-tools", name="")
r = await clients.get(f"/connections/{st['secret_id']}/resources")
assert r.status_code == 200, r.text
got = {x["id"]: x["label"] for x in r.json()["resources"]}
@@ -287,7 +299,7 @@ async def test_instagram_selection_stores_and_binds_the_linked_page_token(
user token stays available for future discovery and reconnect; calls inject only the Page token.
"""
_meta_test_provider(monkeypatch, "instagram")
st = await _connect_byo(clients, provider="instagram", name="instagram")
st = await _connect_byo(clients, provider="instagram", capability="page-tools", name="")
sid = st["secret_id"]
r = await clients.post(f"/connections/{sid}/resource", json={
@@ -303,7 +315,7 @@ async def test_instagram_selection_stores_and_binds_the_linked_page_token(
assert blob["page_access_token"] == expected_token
assert blob["page_id"] == expected_page
tool = (await db.execute(select(Tool).where(
Tool.org_id == secret.org_id, Tool.name == "instagram"
Tool.org_id == secret.org_id, Tool.name == "instagram-page-tools"
))).scalars().one()
assert tool.bindings[0]["secret_field"] == "page_access_token"
@@ -320,7 +332,7 @@ async def test_instagram_selection_subscription_failure_is_atomic(
monkeypatch, "instagram",
resource_setup_path="/{page_id}/missing-subscription-edge",
)
st = await _connect_byo(clients, provider="instagram", name="instagram")
st = await _connect_byo(clients, provider="instagram", capability="page-tools", name="")
sid = st["secret_id"]
r = await clients.post(f"/connections/{sid}/resource", json={
@@ -341,7 +353,7 @@ async def test_resource_provider_io_runs_without_an_open_database_session(
):
"""A slow provider must not hold a database session while resource setup waits on HTTP."""
_meta_test_provider(monkeypatch, "instagram")
st = await _connect_byo(clients, provider="instagram", name="instagram")
st = await _connect_byo(clients, provider="instagram", capability="page-tools", name="")
real_session_maker = connect_use_cases.session_maker
real_resolve = connect_use_cases._resolve_resource_call_token
open_sessions = 0
@@ -373,7 +385,7 @@ async def test_resource_selection_rejects_a_concurrent_reconnect(
):
"""Do not combine a Page token from an old grant with a reconnected root token."""
_meta_test_provider(monkeypatch, "instagram")
st = await _connect_byo(clients, provider="instagram", name="instagram")
st = await _connect_byo(clients, provider="instagram", capability="page-tools", name="")
sid = st["secret_id"]
real_resolve = connect_use_cases._resolve_resource_call_token
@@ -411,7 +423,7 @@ async def test_instagram_selection_rejects_an_unlinked_account_atomically(
clients: AsyncClient, treg_meta_app, monkeypatch,
):
_meta_test_provider(monkeypatch, "instagram")
st = await _connect_byo(clients, provider="instagram", name="instagram")
st = await _connect_byo(clients, provider="instagram", capability="page-tools", name="")
sid = st["secret_id"]
r = await clients.post(f"/connections/{sid}/resource", json={
@@ -428,12 +440,14 @@ async def test_instagram_calls_inject_the_selected_page_token(
clients: AsyncClient, treg_meta_app, monkeypatch,
):
_meta_test_provider(monkeypatch, "instagram", base_url="http://upstream/v25.0")
st = await _connect_byo(clients, provider="instagram", name="instagram")
st = await _connect_byo(clients, provider="instagram", capability="page-tools", name="")
sid = st["secret_id"]
# Emulate an Instagram connection provisioned before Page-token handling shipped. Selecting
# the account should migrate its binding in place; a second OAuth reconnect is not required.
async with session_maker() as db:
tool = (await db.execute(select(Tool).where(Tool.name == "instagram"))).scalars().one()
tool = (await db.execute(select(Tool).where(
Tool.name == "instagram-page-tools"
))).scalars().one()
binding = dict(tool.bindings[0])
binding["secret_field"] = "access_token"
tool.bindings = [binding]
@@ -443,7 +457,10 @@ async def test_instagram_calls_inject_the_selected_page_token(
})
assert selected.status_code == 200, selected.text
r = await clients.get("/call/instagram/PAGE-DIRECT/conversations")
r = await clients.get(
"/call/instagram-page-tools/PAGE-DIRECT/conversations",
params={"platform": "instagram"},
)
assert r.status_code == 200, r.text
assert r.json()["data"][0]["id"] == "IG-CONVERSATION-1"
assert r.json()["data"][0]["page_id"] == "PAGE-DIRECT"
+88 -5
View File
@@ -19,7 +19,7 @@ TUTORIAL = (Path(api.__file__).parent / "web" / "tutorial.html").read_text(encod
# Dialogs reachable from more than one view. Each is opened by a button that exists on both the
# marketplace list and an integration page.
SHARED_DIALOGS = ["tokenAsk", "capAsk", "resPick"]
SHARED_DIALOGS = ["tokenAsk", "capAsk", "methodAsk", "resPick"]
def test_oauth_entry_opens_the_existing_sign_in_modal_without_minting_a_sandbox():
@@ -66,6 +66,41 @@ def test_the_parser_can_actually_see_a_trapped_dialog():
assert _enclosing_views('v-for="p in g.items"') == ["connections"]
def test_separate_oauth_methods_share_one_single_choice_modal():
"""Instagram's second grant used to live in a red provider-page banner while Add account
silently chose direct Login. Multi-method providers now get one radio decision and one CTA;
ordinary providers must retain the old zero/one-method path."""
assert 'v-if="methodAsk"' in INDEX
assert 'v-model="methodAsk.selected"' in INDEX
assert 'class="chip ok">Recommended</span>' in INDEX
assert '@click="continueMethod()">Continue</button>' in INDEX
assert "if(methods.length>1 && !conn)" in INDEX
assert "if(methods.length===1 && !conn) return this.connectProvider" in INDEX
assert "connectProvider(mkProvider,'page-tools')" not in INDEX
assert "Instagram Login {{mkInstagramDirect" not in INDEX
assert "if((method.capabilities||[]).length>1){ this.capAsk=" in INDEX
assert "if(ask && ask.method) return [...(ask.method.capabilities||[])].reverse()" in INDEX
def test_connection_rows_retain_the_shared_production_layout():
assert "<b>{{c.identity_label || c.name}}</b>" in INDEX
assert "{{c.resource_name || c.resource_ref" in INDEX
assert "<span class=\"mono\" :title=\"'treg call '+c.name\">{{c.name}}</span>" in INDEX
assert "c.provider==='instagram'" not in INDEX[INDEX.index('<tr v-for="c in mkConns"') : INDEX.index('</table>', INDEX.index('<tr v-for="c in mkConns"'))]
def test_permission_cards_can_use_method_specific_incremental_benefits():
panel = INDEX[INDEX.index('<div class="tgh">Permissions') : INDEX.index('<!-- Cross-link into the endpoint catalog:')]
assert "mkCapabilityIntro(cap)" in panel
assert "mkCapabilityDetails(cap)" in panel
computed = INDEX[INDEX.index("computed:{") : INDEX.index("methods:{")]
assert "mkCapabilityMethod(cap){" not in computed
logic = INDEX[INDEX.index("mkCapabilityMethod(cap){") : INDEX.index("capLabel(cap){")]
assert "m.capability_details && (m.capability_details[cap]||[]).length" in logic
assert "this.mkProvider&&this.mkProvider.scope_detail" in logic
assert "page-tools" not in logic and "instagram" not in logic
# --- endpoint catalog: the marketplace's platform axis (view==='platform') --------------------
@@ -82,6 +117,12 @@ def test_the_platform_header_stacks_instead_of_putting_providers_in_a_column():
assert ".plat-intro{margin:10px 0 0;max-width:74ch}" in INDEX # readable measure, full-width column
def test_platform_provider_navigation_does_not_wait_for_connection_registry_state():
block = INDEX[INDEX.index("platProviders(){") : INDEX.index("// ---- the ledger ----")]
assert "this.providers.some" not in block
assert "seen.filter(s=>s!=='treg')" in block
def test_platform_view_is_a_top_level_view():
"""`view==='platform'` nested inside another view would render nowhere — the platform cards
would look like dead buttons, exactly the failure the dialog checks above exist for."""
@@ -618,7 +659,7 @@ def test_a_callable_row_is_marked_down_its_leading_edge():
assert ':class="{open:platOpen[r.key], go:r.ready}"' in block
assert ".lrow.go td:first-child{box-shadow:inset 3px 0 0" in INDEX
rows = INDEX[INDEX.index("platRowsAll(){") :][:3400]
assert "ready: eps.some(e=>this.catConnected(e.provider))" in rows
assert "ready: eps.some(e=>this.catEndpointConnected(e))" in rows
def test_cheapest_orders_on_the_servers_usd_not_a_local_fx_constant():
@@ -652,7 +693,7 @@ def test_a_connected_oauth_endpoint_counts_as_the_free_path():
"""The account you already connected is the licence — calling it costs nothing more, which is
usually the right answer and must beat any metered scraper."""
free = INDEX[INDEX.index("capFree(e){") :][:400]
assert "this.catConnected(e.provider)" in free
assert "this.catEndpointConnected(e)" in free
assert "e.scope==='own_account'" in free
assert "e.cost.type==='free'" in free
@@ -818,10 +859,10 @@ def test_the_run_actions_lead_the_expansion_tab_bar():
assert 'openEpTry(e)' in bar and ':class="{primary:!mkOauth(e.provider)}"' in bar
# OAuth → Connect is the ink-fill primary
assert 'v-else-if="mkOauth(e.provider)" class="btn sm primary"' in bar and "openProvider(e.provider)" in bar
assert ">Connect {{e.provider_display||e.provider}}</button>" in bar
assert "{{endpointConnectLabel(e)}}</button>" in bar
# key/token → own key is the secondary
assert 'v-else-if="mkKnown(e.provider)" class="btn sm"' in bar and ">Bring your own key</button>" in bar
assert 'v-if="catConnected(e.provider)" class="chip go"' in bar and ">Connected</span>" in bar
assert 'v-if="catEndpointConnected(e)" class="chip go"' in bar and ">Connected</span>" in bar
assert 'v-if="e.docs_url"' in bar # ...docs sit up here too, not below the fold
assert ".ltabs-r .btn.primary{background:var(--inverse)" in INDEX
@@ -845,6 +886,48 @@ def test_try_it_get_snippets_keep_query_and_auth_without_a_body():
assert block.count("method!=='GET' && this.epTryBody.trim()") == 2
def test_try_it_selects_registry_authorization_and_updates_every_call_surface():
start = INDEX.index('<!-- Try a MARKETPLACE endpoint right here.')
drawer = INDEX[start : INDEX.index('<!-- access reminder toast:', start)]
assert 'v-model="epTryAuthMethod"' in drawer
assert 'v-if="epTryShowAuthSelector"' in drawer
assert 'class="field auth-method-field"' in drawer
assert "authorizationMethodLabel(epTry.provider,m)" in drawer
assert "m==='instagram-login'" not in drawer
logic = INDEX[INDEX.index("epTryVisibleParams(){") : INDEX.index("tryExamples(){")]
assert "--authorization-method ${this.epTryAuthMethod}" in logic
assert 'X-Treg-Authorization-Method: ${this.epTryAuthMethod}' in logic
runner = INDEX[INDEX.index("openEpTry(e){") : INDEX.index("async runTry(){")]
assert "this.epTryAuthMethod=e.authorization_method" in runner
assert "opts.headers['X-Treg-Authorization-Method']=this.epTryAuthMethod" in runner
assert "(this.epTry.authorization_paths||{})[this.epTryAuthMethod]||this.epTry.path" in INDEX
def test_instagram_try_it_only_shows_method_picker_when_both_grants_are_connected():
logic = INDEX[INDEX.index("epTryAuthMethods(){") : INDEX.index("epTryVisibleParams(){")]
assert "a.tier==='tool'||a.tier==='credential'" in logic
assert "this.epTryAuthMethods.length>1 && this.epTryConnectedMethods.length>1" in logic
policy = INDEX[INDEX.index("async loadEpTryAccessPolicy(){") : INDEX.index("async runEpTry(){")]
assert "if(connected.length===1) this.epTryAuthMethod=connected[0]" in policy
assert "else this.epTryAuthMethod=e.authorization_method||methods[0]||''" in policy
def test_multi_method_picker_uses_only_available_methods():
picker = INDEX[INDEX.index("startConnect(p, conn){") : INDEX.index("async continueMethod(){")]
assert "const available=methods.filter(method=>method.configured)" in picker
def test_catalog_connection_badges_require_an_endpoint_compatible_grant():
logic = INDEX[INDEX.index("catConnected(service){") : INDEX.index("catMetered(service){")]
assert "endpointAuthMethods(e)" in logic
assert "c.provider===e.provider && methods.includes(c.authorization_method)" in logic
assert "endpointConnectLabel(e)" in logic
drawer = INDEX[INDEX.index("<!-- MANUAL — the live test form -->") : INDEX.index("<!-- access reminder toast:")]
assert "epTryAccess.connect_command" not in drawer
assert "openProvider(epTry.provider); epTry=null" in drawer
assert "epTryAccess.action_label||endpointConnectLabel(epTry)" in drawer
def test_provider_page_links_into_the_catalog():
"""Navigation runs both ways: an integration page names the platforms its catalog covers."""
assert _enclosing_views('v-for="pl in mkPlatforms"') == ["provider"]
+415
View File
@@ -0,0 +1,415 @@
"""Instagram's two explicit grants and catalog-call authorization contract."""
from __future__ import annotations
import json
from dataclasses import replace
from contextlib import asynccontextmanager
from datetime import timedelta
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import AsyncClient
from sqlmodel import select
from treg import crypto
from treg import oauth_providers
from treg.application import connect as connect_use_cases
from treg.application.call import resolve as call_resolution
from treg.config import get_settings
from treg.infra.db import session_maker
from treg.models import Secret, Tool
from treg.timeutil import utcnow_naive
@pytest.fixture
def instagram_apps(monkeypatch):
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_ID", "instagram-cid")
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_SECRET", "instagram-csec")
monkeypatch.setenv("TREG_META_CLIENT_ID", "meta-cid")
monkeypatch.setenv("TREG_META_CLIENT_SECRET", "meta-csec")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_legacy_instagram_grants_keep_the_facebook_page_profile():
assert oauth_providers.INSTAGRAM.authorization_method_name("") == "facebook-page"
profile = oauth_providers.INSTAGRAM.profile_for_authorization("")
parsed = urlsplit(profile.base_url)
assert parsed.scheme == "https"
assert parsed.hostname == "graph.facebook.com"
assert profile.call_token_field == "page_access_token"
def test_legacy_grant_inference_uses_provider_metadata(monkeypatch):
methods = tuple(
replace(method, name="direct" if index == 0 else "delegated")
for index, method in enumerate(oauth_providers.INSTAGRAM.authorization_methods)
)
provider = replace(
oauth_providers.INSTAGRAM,
service="future-social",
legacy_authorization_method="delegated",
authorization_methods=methods,
)
monkeypatch.setitem(oauth_providers.REGISTRY, provider.service, provider)
secret = Secret(provider=provider.service, authorization_method="")
assert call_resolution._authorization_method(secret) == "delegated"
async def _complete(clients: AsyncClient, body: dict) -> dict:
started = await clients.post("/oauth/start", json=body)
assert started.status_code == 200, started.text
state = started.json()["state"]
callback = await clients.get(f"/oauth/callback?code=AUTHCODE&state={state}")
assert callback.status_code == 200, callback.text
status = (await clients.get(f"/oauth/status/{state}")).json()
assert status["status"] == "done", status
return {**started.json(), **status}
async def test_plain_instagram_connect_uses_direct_login_and_records_identity(
clients: AsyncClient, instagram_apps,
):
started = await clients.post("/oauth/start", json={"provider": "instagram"})
assert started.status_code == 200, started.text
query = parse_qs(urlsplit(started.json()["consent_url"]).query)
assert urlsplit(started.json()["consent_url"]).netloc == "www.instagram.com"
assert query["client_id"] == ["instagram-cid"]
assert query["enable_fb_login"] == ["false"]
assert "instagram_business_manage_messages" in query["scope"][0].split(",")
state = started.json()["state"]
assert (await clients.get(f"/oauth/callback?code=AUTHCODE&state={state}")).status_code == 200
status = (await clients.get(f"/oauth/status/{state}")).json()
rows = (await clients.get("/connections")).json()
connection = next(row for row in rows if row["id"] == status["secret_id"])
assert connection["authorization_method"] == "instagram-login"
assert connection["resource_ref"] == "17841400000000000"
assert connection["resource_name"] == "direct_ig"
assert connection["health"] == "ok"
assert connection["supports_discovery"] is False
async with session_maker() as db:
secret = await db.get(Secret, status["secret_id"])
blob = json.loads(crypto.decrypt(secret.value))
assert blob["access_token"] == "IG-LONG-TOKEN"
assert blob["refresh_grant_type"] == "ig_refresh_token"
tool = (await db.execute(select(Tool).where(Tool.name == "instagram"))).scalars().one()
assert tool.host == "graph.instagram.com"
assert tool.bindings[0]["secret_field"] == "access_token"
async def test_page_tools_are_a_separate_facebook_grant(
clients: AsyncClient, instagram_apps,
):
direct = await _complete(clients, {"provider": "instagram"})
page = await clients.post(
"/oauth/start", json={"provider": "instagram", "capability": "page-tools"},
)
assert page.status_code == 200, page.text
assert "linked to a Facebook Page" in page.json()["connect_guidance"]
assert "separate from Instagram Login" in page.json()["connect_guidance"]
query = parse_qs(urlsplit(page.json()["consent_url"]).query)
assert urlsplit(page.json()["consent_url"]).netloc == "www.facebook.com"
assert query["client_id"] == ["meta-cid"]
assert "pages_show_list" in query["scope"][0].split()
state = page.json()["state"]
assert (await clients.get(f"/oauth/callback?code=AUTHCODE&state={state}")).status_code == 200
page_status = (await clients.get(f"/oauth/status/{state}")).json()
rows = {row["id"]: row for row in (await clients.get("/connections")).json()}
assert rows[direct["secret_id"]]["authorization_method"] == "instagram-login"
assert rows[page_status["secret_id"]]["authorization_method"] == "facebook-page"
assert rows[page_status["secret_id"]]["name"] == "instagram-page-tools"
assert len(rows) == 2
async def test_catalog_call_selects_instagram_grant_when_facebook_shares_the_host(
clients: AsyncClient, instagram_apps,
):
await _complete(clients, {"provider": "facebook"})
page = await _complete(
clients, {"provider": "instagram", "capability": "page-tools"},
)
selected = await clients.post(
f"/connections/{page['secret_id']}/resource",
json={"resource_ref": "IG-DIRECT", "resource_name": "direct_ig"},
)
assert selected.status_code == 200, selected.text
response = await clients.get(
"/call/instagram.x.user-messages",
params={"page_id": "PAGE-DIRECT", "platform": "instagram"},
)
assert response.status_code == 200, response.text
assert response.json()["data"][0]["id"] == "IG-CONVERSATION-1"
async def test_marketplace_secret_uses_the_newest_grant_for_a_method(
clients: AsyncClient,
):
async with session_maker() as db:
old = Secret(
org_id=1, name="instagram-old", owner="owner@example.com", kind="oauth",
value=crypto.encrypt("{}"), provider="instagram",
authorization_method="instagram-login",
)
new = Secret(
org_id=1, name="instagram-new", owner="owner@example.com", kind="oauth",
value=crypto.encrypt("{}"), provider="instagram",
authorization_method="instagram-login",
)
db.add(old)
await db.flush()
db.add(new)
await db.commit()
selected = await call_resolution._marketplace_secret(
"instagram", 1, db, ("instagram-login",),
)
assert selected is not None
assert selected.id == new.id
async def test_catalog_call_explicitly_selects_each_instagram_authorization(
clients: AsyncClient, instagram_apps,
):
await _complete(clients, {"provider": "instagram"})
page = await _complete(clients, {"provider": "instagram", "capability": "page-tools"})
selected = await clients.post(
f"/connections/{page['secret_id']}/resource",
json={"resource_ref": "IG-DIRECT", "resource_name": "direct_ig"},
)
assert selected.status_code == 200, selected.text
direct = await clients.get(
"/call/instagram.x.user-messages",
params={"ig_user_id": "17841400000000000"},
headers={"X-Treg-Authorization-Method": "instagram-login"},
)
assert direct.status_code == 200, direct.text
assert direct.json()["raw_path"] == "/v25.0/17841400000000000/conversations"
assert direct.json()["auth"] == "Bearer IG-LONG-TOKEN"
page_call = await clients.get(
"/call/instagram.x.user-messages",
params={"page_id": "PAGE-DIRECT", "platform": "instagram"},
headers={"X-Treg-Authorization-Method": "facebook-page"},
)
assert page_call.status_code == 200, page_call.text
assert page_call.json()["data"][0]["page_id"] == "PAGE-DIRECT"
missing_platform = await clients.get(
"/call/instagram.x.user-messages",
params={"page_id": "PAGE-DIRECT"},
headers={"X-Treg-Authorization-Method": "facebook-page"},
)
assert missing_platform.status_code == 400, missing_platform.text
assert "platform=<value>" in missing_platform.json()["detail"]
async def test_access_dry_run_reports_each_instagram_grant_independently(
clients: AsyncClient, instagram_apps,
):
await _complete(clients, {"provider": "instagram", "capability": "page-tools"})
direct = await clients.get(
"/catalog/endpoints/instagram.x.user-messages/access",
params={"authorization_method": "instagram-login"},
)
assert direct.status_code == 200, direct.text
assert direct.json()["tier"] == "none"
page = await clients.get(
"/catalog/endpoints/instagram.x.user-messages/access",
params={"authorization_method": "facebook-page"},
)
assert page.status_code == 200, page.text
assert page.json()["tier"] in {"tool", "credential"}
assert page.json()["authorization_method"] == "facebook-page"
async def test_page_only_access_dry_run_preserves_connect_guidance(
clients: AsyncClient, instagram_apps,
):
response = await clients.get(
"/catalog/endpoints/instagram.x.user-recently-searched-hashtags/access",
params={"authorization_method": "facebook-page"},
)
assert response.status_code == 200, response.text
assert response.json() == {
"tier": "none",
"authorization_method": "facebook-page",
"connect_capability": "page-tools",
"connect_command": "treg connections connect --provider instagram --capability page-tools",
"action_label": "Enable Facebook Page tools",
"missing_message": (
"This tool requires Facebook Page authorization and an Instagram Professional "
"account linked to that Page."
),
"detail": (
"no instagram credential in this org yet — connect with: "
"treg connections connect --provider instagram --capability page-tools"
),
}
async def test_instagram_method_rejects_the_other_methods_identifier(
clients: AsyncClient, instagram_apps,
):
await _complete(clients, {"provider": "instagram"})
response = await clients.get(
"/call/instagram.x.user-messages",
params={"ig_user_id": "17841400000000000", "page_id": "PAGE-DIRECT"},
headers={"X-Treg-Authorization-Method": "instagram-login"},
)
assert response.status_code == 400, response.text
assert "does not accept page_id with instagram-login" in response.json()["detail"]
async def test_required_identity_lookup_runs_after_database_session_closes(
clients: AsyncClient, instagram_apps, monkeypatch,
):
real_session_maker = connect_use_cases.session_maker
real_identity = connect_use_cases._record_connected_identity
open_sessions = 0
@asynccontextmanager
async def tracked_session_maker():
nonlocal open_sessions
open_sessions += 1
try:
async with real_session_maker() as db:
yield db
finally:
open_sessions -= 1
async def checked_identity(*args, **kwargs):
assert open_sessions == 0
return await real_identity(*args, **kwargs)
monkeypatch.setattr(connect_use_cases, "session_maker", tracked_session_maker)
monkeypatch.setattr(connect_use_cases, "_record_connected_identity", checked_identity)
await _complete(clients, {"provider": "instagram"})
async def test_page_discovery_runs_after_database_session_closes(
clients: AsyncClient, instagram_apps, monkeypatch,
):
from treg.api import app
page = await _complete(
clients, {"provider": "instagram", "capability": "page-tools"},
)
real_session_maker = connect_use_cases.session_maker
real_client = app.state.http
open_sessions = 0
@asynccontextmanager
async def tracked_session_maker():
nonlocal open_sessions
open_sessions += 1
try:
async with real_session_maker() as db:
yield db
finally:
open_sessions -= 1
class CheckedClient:
async def get(self, *args, **kwargs):
assert open_sessions == 0
return await real_client.get(*args, **kwargs)
monkeypatch.setattr(connect_use_cases, "session_maker", tracked_session_maker)
result = await connect_use_cases.list_connection_resources(
secret_id=page["secret_id"], org_id=1, client_factory=CheckedClient,
)
assert {item["id"] for item in result["resources"]} == {"IG-DIRECT", "IG-CLIENT"}
async def test_no_direct_professional_account_is_setup_required(
clients: AsyncClient, instagram_apps, monkeypatch,
):
monkeypatch.setitem(
oauth_providers.REGISTRY,
"instagram",
replace(oauth_providers.INSTAGRAM, identity_path="/missing-professional-account"),
)
connected = await _complete(clients, {"provider": "instagram"})
rows = {row["id"]: row for row in (await clients.get("/connections")).json()}
connection = rows[connected["secret_id"]]
assert connection["health"] == "setup_required"
assert "Business or Creator account" in connection["health_detail"]
assert connection["health_detail"] == oauth_providers.INSTAGRAM.identity_missing_detail
async with session_maker() as db:
tool = (await db.execute(select(Tool).where(Tool.name == "instagram"))).scalars().first()
assert tool is None
async def test_empty_page_discovery_is_not_reported_as_working(
clients: AsyncClient, instagram_apps, monkeypatch,
):
provider = oauth_providers.INSTAGRAM
methods = tuple(
replace(
method,
overrides=tuple({
**dict(method.overrides),
"discover_path": "/empty-resources",
"discover_extra_path": "",
}.items()),
) if method.name == "facebook-page" else method
for method in provider.authorization_methods
)
monkeypatch.setitem(
oauth_providers.REGISTRY, "instagram", replace(provider, authorization_methods=methods),
)
page = await _complete(
clients, {"provider": "instagram", "capability": "page-tools"},
)
response = await clients.get(f"/connections/{page['secret_id']}/resources")
assert response.status_code == 200, response.text
assert response.json()["setup_required"] is True
assert "linked to a Facebook Page" in response.json()["setup_detail"]
rows = {row["id"]: row for row in (await clients.get("/connections")).json()}
assert rows[page["secret_id"]]["health"] == "setup_required"
async def test_direct_connection_does_not_satisfy_a_page_only_tool(
clients: AsyncClient, instagram_apps,
):
await _complete(clients, {"provider": "instagram"})
response = await clients.get("/call/instagram.x.hashtag-search")
assert response.status_code == 428, response.text
detail = response.json()["detail"]
assert detail["error"] == "authorization_missing"
assert detail["required_authorization_method"] == "facebook-page"
assert detail["required_capability"] == "page-tools"
assert detail["cli_command"] == (
"treg connections connect --provider instagram --capability page-tools"
)
assert detail["message"] == (
"This tool requires Facebook Page authorization and an Instagram Professional account "
"linked to that Page."
)
async def test_expired_grant_returns_structured_reconnect_guidance(
clients: AsyncClient, instagram_apps,
):
connected = await _complete(clients, {"provider": "instagram"})
async with session_maker() as db:
secret = await db.get(Secret, connected["secret_id"])
blob = json.loads(crypto.decrypt(secret.value))
blob.pop("refresh_token", None)
secret.value = crypto.encrypt(json.dumps(blob))
secret.expires_at = utcnow_naive() - timedelta(minutes=1)
await db.commit()
response = await clients.get(
"/call/instagram.instagram.user.profile",
params={"ig_user_id": "17841400000000000", "fields": "id,username"},
)
assert response.status_code == 428, response.text
detail = response.json()["detail"]
assert detail["error"] == "authorization_expired"
assert detail["required_authorization_method"] == "instagram-login"
+21
View File
@@ -196,6 +196,8 @@ async def test_v2_declares_exact_directory_contract():
assert "my_tools" not in tools and "call" not in tools
assert "method" not in tools["catalog_call_read"].input_schema["properties"]
assert "method" not in tools["catalog_call_write"].input_schema["properties"]
assert "authorization_method" in tools["catalog_call_read"].input_schema["properties"]
assert "authorization_method" in tools["catalog_call_write"].input_schema["properties"]
for tool in tools.values():
assert tool.output_schema and tool.output_schema.get("properties")
assert not tool.output_schema.get("required")
@@ -298,6 +300,8 @@ async def test_v2_serializes_the_scanner_facing_contract(clients):
assert tools["catalog_call_write"]["annotations"]["destructiveHint"] is True
assert "method" not in tools["catalog_call_read"]["inputSchema"]["properties"]
assert "method" not in tools["catalog_call_write"]["inputSchema"]["properties"]
assert "authorization_method" in tools["catalog_call_read"]["inputSchema"]["properties"]
assert "authorization_method" in tools["catalog_call_write"]["inputSchema"]["properties"]
async def test_v2_shared_catalog_details_balance_and_guidance_work_end_to_end(clients):
@@ -405,6 +409,23 @@ async def test_team_and_directory_catalog_call_errors_match_or_keep_the_document
assert "catalog endpoint id" in directory_unknown["hint"]
async def test_both_mcp_surfaces_preserve_oauth_remediation_fields(clients):
token = clients.headers["X-Treg-Token"]
args = {"endpoint_id": "instagram.x.hashtag-search"}
async with paired_mcp_session() as client:
team = await _call_tool(client, "call", args, token, path="/mcp/")
directory = await _call_tool(client, "catalog_call_read", args, token)
assert team["status"] == directory["status"] == 428
assert team["body"] == directory["body"]
detail = team["body"]["detail"]
assert detail["error"] == "authorization_missing"
assert detail["provider"] == "instagram"
assert detail["required_authorization_method"] == "facebook-page"
assert detail["required_capability"] == "page-tools"
assert detail["cli_command"].endswith("--capability page-tools")
async def test_v2_search_and_catalog_request_keep_directory_attribution(clients):
from sqlmodel import select
+28 -19
View File
@@ -27,6 +27,10 @@ def all_apps(monkeypatch):
for k in ("GOOGLE", "SLACK", "X", "TIKTOK"):
monkeypatch.setenv(f"TREG_{k}_CLIENT_ID", f"{k.lower()}-cid")
monkeypatch.setenv(f"TREG_{k}_CLIENT_SECRET", f"{k.lower()}-csec")
monkeypatch.setenv("TREG_META_CLIENT_ID", "meta-cid")
monkeypatch.setenv("TREG_META_CLIENT_SECRET", "meta-csec")
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_ID", "instagram-cid")
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_SECRET", "instagram-csec")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@@ -267,14 +271,14 @@ async def test_linkedin_does_not_get_googles_consent_params(clients: AsyncClient
get_settings.cache_clear()
def test_meta_providers_share_one_app():
"""Facebook and Instagram are one Meta app, deliberately.
App Review, business verification and Tech Provider status attach to the app, so a second
OAuth client isolates nothing and starts its approval from zero."""
assert P.FACEBOOK.client_id_setting == P.INSTAGRAM.client_id_setting == "meta_client_id"
assert P.FACEBOOK.client_secret_setting == P.INSTAGRAM.client_secret_setting == "meta_client_secret"
assert P.FACEBOOK.base_url == P.INSTAGRAM.base_url
def test_instagram_direct_and_page_grants_use_explicit_app_profiles():
assert P.INSTAGRAM.client_id_setting == "instagram_client_id"
parsed = urlsplit(P.INSTAGRAM.base_url)
assert parsed.scheme == "https"
assert parsed.hostname == "graph.instagram.com"
page = P.INSTAGRAM.profile_for_authorization("facebook-page")
assert page.client_id_setting == P.FACEBOOK.client_id_setting == "meta_client_id"
assert page.base_url == P.FACEBOOK.base_url
def test_meta_capabilities_are_cumulative():
@@ -298,7 +302,8 @@ def test_meta_messaging_stays_out_of_the_publish_tier():
for provider in (P.FACEBOOK, P.INSTAGRAM):
for cap in ("read", "post"):
assert not two_way & set(provider.scopes[cap]), (provider.service, cap)
assert {"instagram_manage_messages", "pages_messaging"} <= set(P.INSTAGRAM.scopes["manage"])
assert "instagram_business_manage_messages" in P.INSTAGRAM.scopes["manage"]
assert {"instagram_manage_messages", "pages_messaging"} <= set(P.INSTAGRAM.scopes["page-tools"])
def test_lead_retrieval_brings_its_required_rider():
@@ -308,18 +313,20 @@ def test_lead_retrieval_brings_its_required_rider():
assert {"leads_retrieval", "pages_manage_ads"} <= manage
def test_instagram_is_reached_through_a_page():
"""An Instagram professional account has no listing endpoint of its own — it hangs off the
linked Page — so dropping pages_show_list silently empties the account picker."""
for cap in P.INSTAGRAM.scopes.values():
assert "pages_show_list" in cap
assert P.INSTAGRAM.discover_id_field == "instagram_business_account.id"
def test_instagram_login_is_direct_and_page_discovery_is_optional():
for cap in ("read", "post", "manage"):
assert "pages_show_list" not in P.INSTAGRAM.scopes[cap]
assert P.INSTAGRAM.identity_required is True
page = P.INSTAGRAM.profile_for_authorization("facebook-page")
assert page.discover_id_field == "instagram_business_account.id"
assert "pages_show_list" in P.INSTAGRAM.scopes["page-tools"]
def test_meta_asks_for_a_long_lived_token():
"""Meta's code exchange yields a ~1-2h token and no refresh_token. Without the second
exchange every Meta connection dies the day it is made."""
assert P.FACEBOOK.long_lived_exchange and P.INSTAGRAM.long_lived_exchange
assert P.FACEBOOK.long_lived_exchange
assert P.INSTAGRAM.long_lived_exchange_style == "instagram"
assert not P.TIKTOK.long_lived_exchange # nothing else should have picked it up
@@ -335,9 +342,11 @@ def test_meta_page_discovery_can_walk_the_business_graph():
portfolio, where the member has business-level access and no personal Page role. Drop
business_management from either capability and that user consents cleanly, then gets an empty
picker — the extra listing 400s and is (rightly) swallowed."""
for provider in (P.FACEBOOK, P.INSTAGRAM):
for cap in provider.scopes.values():
assert "business_management" in cap, provider.service
for cap in P.FACEBOOK.scopes.values():
assert "business_management" in cap
page = P.INSTAGRAM.profile_for_authorization("facebook-page")
assert "business_management" in P.INSTAGRAM.scopes["page-tools"]
for provider in (P.FACEBOOK, page):
assert provider.discover_extra_path.startswith("/me/businesses"), provider.service
assert provider.discover_extra_list_paths, provider.service
+51 -7
View File
@@ -117,6 +117,25 @@ async def test_unconfigured_provider_is_a_clear_422(clients: AsyncClient, monkey
get_settings.cache_clear()
def test_multi_method_provider_is_configured_when_any_method_is_available(monkeypatch):
"""The registry gives every client one correct provider-level availability value."""
from treg import oauth_providers as P
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_ID", "")
monkeypatch.setenv("TREG_INSTAGRAM_CLIENT_SECRET", "")
monkeypatch.setenv("TREG_META_CLIENT_ID", "meta-cid")
monkeypatch.setenv("TREG_META_CLIENT_SECRET", "meta-secret")
get_settings.cache_clear()
try:
row = next(item for item in P.listing() if item["service"] == "instagram")
methods = {item["name"]: item for item in row["authorization_methods"]}
assert row["configured"] is True
assert methods["instagram-login"]["configured"] is False
assert methods["facebook-page"]["configured"] is True
finally:
get_settings.cache_clear()
async def test_unknown_capability_is_422(clients: AsyncClient, treg_google_app):
r = await clients.post(
"/oauth/start", json={"provider": "google-search-console", "capability": "nope"}
@@ -172,7 +191,7 @@ def test_scope_label_falls_back_rather_than_raising():
# Facebook's consent screen shows only that bare app name. Without a notice on the treg side, the
# popup asks the user to authorize a product they have never heard of.
META_SERVICES = ["facebook", "instagram", "meta-ads"]
META_SERVICES = ["facebook", "meta-ads"]
def test_meta_providers_disclose_the_crewlet_app_name():
@@ -183,6 +202,24 @@ def test_meta_providers_disclose_the_crewlet_app_name():
notice = rows[service]["consent_notice"]
assert "Crewlet" in notice, service
assert "Superdesign Dev Inc" in notice, service
page = next(
method for method in rows["instagram"]["authorization_methods"]
if method["name"] == "facebook-page"
)
assert "Crewlet" in page["consent_notice"]
assert page["connect_capability"] == "page-tools"
assert page["action_label"] == "Enable Facebook Page tools"
assert "linked to a Facebook Page" in page["description"]
assert "separate from Instagram Login" in page["description"]
assert "Facebook Page authorization" in page["missing_message"]
assert "Adds these Page-only tools" in page["capability_intros"]["page-tools"]
assert page["capability_details"]["page-tools"] == [
"Search hashtags and read recent or top hashtag media",
"Discover another professional account by username",
"Read media, comments and tags that mention your account",
"Search the linked product catalog and inspect product appeals",
"Read this account's recently searched hashtags",
]
def test_only_the_meta_family_carries_a_consent_notice():
@@ -192,15 +229,22 @@ def test_only_the_meta_family_carries_a_consent_notice():
meta_auth = {p.service for p in P.REGISTRY.values() if p.auth_uri == P._META_AUTH}
assert meta_auth == set(META_SERVICES)
with_notice = {row["service"] for row in P.listing() if row["consent_notice"]}
assert with_notice == meta_auth
assert with_notice == meta_auth | {"instagram"}
def test_a_notice_provider_always_reaches_the_capability_modal():
"""The dashboard renders `consent_notice` ONLY in the capability modal, and `startConnect`
skips that modal for a single-capability provider. So a provider carrying a notice must have
at least two capabilities, or its disclosure silently never renders."""
def test_a_notice_provider_always_reaches_a_pre_consent_modal():
"""A provider-level notice needs a modal before redirect. Traditional providers reach the
capability modal; a provider with separate authorization methods reaches the method picker,
whose listing entries carry the profile-specific notice."""
from treg import oauth_providers as P
for p in P.REGISTRY.values():
if p.consent_notice:
if not p.consent_notice:
continue
if p.authorization_methods:
assert all(
p.profile_for_authorization(method.name).consent_notice
for method in p.authorization_methods
)
else:
assert len(p.capabilities) >= 2, p.service