mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
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:
@@ -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` |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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, … |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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}",
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = ""
|
||||
|
||||
@@ -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))
|
||||
@@ -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)}"
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
@@ -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
@@ -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"}),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user