Merge pull request #230 from superdesigndev/feat/google-tag-man-v1

feat(oauth): add Google Tag Manager support
This commit is contained in:
Taus
2026-08-28 06:01:51 +06:00
committed by GitHub
16 changed files with 3586 additions and 13 deletions
+4 -1
View File
@@ -31,6 +31,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_validate.py` | architecture/catalog.md |
| `scripts/dump_surface.py` | architecture/composition.md |
| `scripts/minimax_plugin.py` | interface/skill.md |
@@ -92,6 +93,8 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/catalog/fx.yaml` | architecture/catalog.md |
| `src/treg/catalog/google-search-console.extended.yaml` | architecture/catalog.md |
| `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/justoneapi.extended.yaml` | architecture/catalog.md |
| `src/treg/catalog/tikhub.extended.yaml` | architecture/catalog.md |
| `src/treg/catalog_store.py` | architecture/catalog.md, interface/api.md, interface/catalog-review-proposal.md |
@@ -179,7 +182,7 @@ Regenerate via `scripts/build-map.py`.
|---|---|
| `architecture/ads-conversions.md` | `adsconv.py`, `signup.py`, `adtrack.js` |
| `architecture/auth-secrets.md` | `injectors.py`, `crypto.py`, `oauth.py`, `oauth_providers.py`, `health.py`, `connect.py`, `connections.py`, `resources.py` |
| `architecture/catalog.md` | `catalog-drift.yml`, `catalog_drift.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`, `justoneapi.extended.yaml`, `tikhub.extended.yaml`, `catalog_store.py`, `endpoint_stats.py`, `catalog.py` |
| `architecture/catalog.md` | `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`, `justoneapi.extended.yaml`, `tikhub.extended.yaml`, `catalog_store.py`, `endpoint_stats.py`, `catalog.py` |
| `architecture/composition.md` | `bootstrap.py`, `bootstrap_http.py`, `connect.py`, `mcp_oauth.py`, `session.py`, `admin.py`, `auth.py`, `billing.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `web.py`, `dump_surface.py` |
| `architecture/data-model.md` | `alembic.ini`, `env.py`, `0001_baseline_current_schema.py`, `sitetrack.js`, `models.py`, `timeutil.py`, `db.py`, `referrals.py`, `referrals.py`, `audit.py`, `analytics.py`, `ratestore.py`, `auth.py` |
| `architecture/import-boundaries.md` | `pyproject.toml`, `ci.yml`, `__init__.py`, `__init__.py`, `__init__.py`, `teams.py`, `__init__.py`, `test_import_lightness.py` |
+1 -1
View File
@@ -18,7 +18,7 @@ covers (frontmatter `sources:`). Regenerate this index with
|---|---|---|
| [Google Ads conversion tracking — capture, outbox, upload](architecture/ads-conversions.md) | shipped | adsconv.py, signup.py, adtrack.js |
| [Auth & secrets — injectors, encryption, OAuth freshness, health](architecture/auth-secrets.md) | shipped | injectors.py, crypto.py, oauth.py, oauth_providers.py, … |
| [Endpoint catalog — what you can DO with a connected key, and which provider should do it](architecture/catalog.md) | shipped | catalog-drift.yml, catalog_drift.py, catalog_validate.py, aliases.yaml, … |
| [Endpoint catalog — what you can DO with a connected key, and which provider should do it](architecture/catalog.md) | shipped | catalog-drift.yml, catalog_drift.py, catalog_ingest.py, catalog_validate.py, … |
| [Application composition and deployment roles](architecture/composition.md) | shipped | bootstrap.py, bootstrap_http.py, connect.py, mcp_oauth.py, … |
| [Data model — the registry tables, async DB, audit writer](architecture/data-model.md) | shipped | alembic.ini, env.py, 0001_baseline_current_schema.py, sitetrack.js, … |
| [Enforced import boundaries](architecture/import-boundaries.md) | shipped | pyproject.toml, ci.yml, __init__.py, __init__.py, … |
+10 -1
View File
@@ -90,7 +90,7 @@ unrenewable one earns a warning (`EXPIRING_SOON_DAYS=7`). `connection_view()` is
## Curated OAuth provider registry (`oauth_providers.py`)
Two ways to connect a provider. **Bring-your-own (BYO):** `POST /oauth/start` takes a caller-supplied
`client_id`/`client_secret`/URIs — works for any OAuth2 provider. **Curated:** for the providers where
**treg itself holds the approved app** (Google Search Console/Analytics/Business Profile/Ads, YouTube,
**treg itself holds the approved app** (Google Search Console/Analytics/Business Profile/Tag Manager/Ads, YouTube,
LinkedIn, X, TikTok, Facebook, Instagram, Meta Ads — added PRs #20/#21), the user picks a provider and
consents, supplying nothing. The asymmetry is the point of a hosted registry: the gating cost on these
platforms is the *approval* (a Google Ads developer token, Meta App Review), not the OAuth dance — treg
@@ -104,6 +104,15 @@ Google Search Console's hand-written tool example calls out its distinct direct-
substitute `{site_url}` with a value encoded exactly once (`sc-domain%3Aexample.com`), and never encode
again a property identifier returned by the sites list.
Google Tag Manager shares the standard Google client credentials and exposes three cumulative tiers:
`read` grants `tagmanager.readonly`; `write` adds `tagmanager.edit.containers`; `manage` adds
`tagmanager.edit.containerversions` and `tagmanager.publish`. The account list is both the health probe
and resource picker, with the selected `accounts/{id}` path stamped into the tool example. treg
deliberately does **not** request `tagmanager.delete.containers`, `tagmanager.manage.users`, or
`tagmanager.manage.accounts`: agents can audit configuration, prepare workspace changes, create
versions, and publish (including rollback by publishing an earlier version), but cannot delete whole
containers or administer access.
Each entry is a frozen `OAuthProvider` dataclass; `REGISTRY` is the `{service: provider}` map. Key
module symbols:
- `get(service)` — look up one provider. `credentials(provider)` — treg's own id/secret (raises if this
+14 -1
View File
@@ -4,6 +4,7 @@ status: shipped
sources:
- .github/workflows/catalog-drift.yml
- scripts/catalog_drift.py
- scripts/catalog_ingest.py
- scripts/catalog_validate.py
- src/treg/catalog/aliases.yaml
- src/treg/catalog/fx.yaml
@@ -40,6 +41,8 @@ sources:
- src/treg/catalog/examples/crustdata.people.search.json
- src/treg/catalog/google-search-console.yaml
- src/treg/catalog/google-search-console.extended.yaml
- src/treg/catalog/google-tag-manager.yaml
- src/treg/catalog/google-tag-manager.extended.yaml
- src/treg/catalog/justoneapi.extended.yaml
- src/treg/catalog/tikhub.extended.yaml
- src/treg/catalog_store.py
@@ -120,6 +123,11 @@ Path placeholders are substituted by the marketplace caller. Raw values are perc
that already contains a valid `%HH` escape is kept verbatim so callers can safely reuse encoded resource
names returned by an upstream API. An invalid/literal `%` is still encoded as `%25`. Search Console's
`siteUrl` examples deliberately use the raw `sc-domain:example.com` form to demonstrate the default path.
Google Tag Manager is the opposite case: its `parent`/`path` values describe a hierarchy rather than
one opaque identifier, so the curated catalog exposes atomic account/container/workspace/version ids.
`catalog_ingest.google_flat_path_params` makes the generated GTM input schema use the same atomic
placeholders already present in Discovery's `flatPath`; no slash-delimited resource name is passed
through one placeholder and accidentally encoded as `%2F`.
## Where things live
@@ -696,7 +704,7 @@ can say "bring your own key" *before* the call instead of relaying the 403 after
calls it `douyin`; if both don't land on `douyin`, the marketplace shelf splits in two and the
cross-provider comparison the catalog exists for silently stops working.
### The first-party OAuth wave (2026-07-28)
### The first-party OAuth wave (2026-07-28; Google Tag Manager added 2026-08-27)
The scraper providers sell breadth and their extended tier reads as a menu. The nine providers
where treg owns the OAuth app are the opposite question — *what can this one connected account
@@ -706,6 +714,7 @@ actually do?* — and their sources differ per provider:
|---|---|---|---|
| google-search-console | searchconsole v1 discovery | 7 | 0 |
| google-analytics | analyticsdata + analyticsadmin v1beta discovery | 63 (55 on the admin host) | 32 |
| google-tag-manager | tagmanager v2 discovery | 98 | 8 |
| google-business-profile | six My Business discovery docs + 7 hand-listed legacy v4 routes | 60 (45 off-host) | n/a |
| youtube | youtube v3 discovery + the published quota-cost table | 76 | 2 |
| google-ads | the GAQL resource reference — one entry per queryable resource | 42 | 0 |
@@ -721,6 +730,10 @@ Three things generalise from it:
HTML reference. Scopes are ALTERNATIVES (holding any one suffices), so coverage is an
intersection, not a subset. The My Business documents are the exception that declares no scopes
at all, which is why that provider has no computable gaps.
- **Google Tag Manager keeps risky administration outside the grant.** Its core catalog presents an
audit → workspace edit → version/publish workflow across cumulative `read`/`write`/`manage` tiers.
The generated catalog still lists methods requiring container deletion or account/user management,
but marks all eight with `scope_gap`; those three scopes are intentionally never requested.
- **Google Ads is a resource list, not a route list.** One endpoint (`googleAds:searchStream`)
answers every read and what varies is the GAQL `FROM` clause, so the unit of coverage is the
queryable resource. Forty entries share a path and differ in `input.note` and `docs_url`.
+6 -1
View File
@@ -334,7 +334,7 @@ Server side (`domain.identity.access`): `require_identity` (who, from token OR s
## Marketplace — the in-browser OAuth-connect UI (`view==='connections'` / `'provider'`)
The dashboard now runs the whole **hosted connect flow** in the browser, so a member can attach a
provider account (Google Analytics, Search Console, Google Ads, Slack, Meta/Facebook/Instagram, X,
provider account (Google Analytics, Search Console, Google Tag Manager, Google Ads, Slack, Meta/Facebook/Instagram, X,
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`,
@@ -362,6 +362,11 @@ still opens the provider page (`openProvider(service)`); each row has `id="prov-
Rows show the **provider logo** served by convention from
**`/logos/<service>.svg`** (`.plogo-tile`/`.plogo`, `@error` hides a missing file) — the `StaticFiles`
mount `_LOGO_DIR` (`src/treg/web/logos/`). `connCount` labels how many accounts are already connected.
Google Tag Manager follows that same generic UI: its capability picker offers cumulative
read/write/manage access, account discovery labels each `accounts/{id}` resource by name, and the
selected account stamps a runnable containers-list path into the provisioned tool. Its provider and
platform logo assets both carry the Google Tag Manager mark, so the catalog tile, platform header,
provider page, and expanded endpoint rows resolve to the same identity.
The tab bar itself is `v-if`'d on `plats.list.length` and `mkTabActive` collapses to `'platform'` when
the catalog is absent, so a build that predates `/catalog` renders exactly the old marketplace.
+83 -2
View File
@@ -224,7 +224,7 @@ def write_extended(provider: str, source: dict, endpoints: list[dict], notes: li
"# `capability` mappings (with their platform correction) are added later and carried across",
f"# re-ingests by id via carry_verification. Routes curated in core {provider}.yaml are excluded here.",
]
header += [f"# {n}" for n in notes]
header += [f"# {n}" if n else "#" for n in notes]
body = yaml.safe_dump(
{"provider": provider, "source": source, "endpoints": endpoints},
sort_keys=False,
@@ -1125,7 +1125,44 @@ def google_entry(
return entry
FREE_QUOTA = {"type": "free", "value": 0.0, "currency": "USD",
def google_flat_path_params(entry: dict) -> dict:
"""Align Discovery parameters with the atomic placeholders in Google's ``flatPath``.
Discovery describes hierarchical routes semantically as one ``parent``/``path``/``name``
resource, while ``flatPath`` expands that resource into placeholders such as ``accountsId`` and
``containersId``. treg intentionally percent-encodes each catalog placeholder as one path
segment, so advertising the semantic resource name would both generate an unusable command and
encode its hierarchy as ``%2F``. Keep the flattened route and expose its atomic ids instead.
This helper is initially applied only to GTM. Analytics and Business Profile have older
generated metadata with the same mismatch; repairing and regenerating those catalogs belongs in
a separate change rather than silently broadening the GTM provider diff.
"""
names = list(dict.fromkeys(re.findall(r"{([A-Za-z0-9_]+)}", str(entry.get("path") or ""))))
if not names:
return entry
inp = entry.get("input")
if not isinstance(inp, dict):
inp = {}
entry["input"] = inp
current = inp.get("pathParams")
if isinstance(current, dict) and set(current) == set(names):
return entry
inp["pathParams"] = {
name: {
"type": "string",
"required": True,
"note": (
f"Atomic {name} path segment from Google's expanded resource name; "
"do not pass a slash-delimited parent/path value"
),
}
for name in names
}
return entry
FREE_QUOTA = {"type": "free", "value": 0.0, "currency": "USD", "unit": "call",
"note": "no per-call charge; billed against the API's daily quota"}
@@ -1217,6 +1254,49 @@ def ingest_google_analytics(refresh: bool) -> tuple[Path, dict]:
), endpoints, notes), {"scope_gaps": gaps, "admin": admin}
# --- google-tag-manager ------------------------------------------------------------------------
def ingest_google_tag_manager(refresh: bool) -> tuple[Path, dict]:
provider = "google-tag-manager"
granted = {
"https://www.googleapis.com/auth/tagmanager.readonly",
"https://www.googleapis.com/auth/tagmanager.edit.containers",
"https://www.googleapis.com/auth/tagmanager.edit.containerversions",
"https://www.googleapis.com/auth/tagmanager.publish",
}
skip = core_route_keys(provider)
endpoints, gaps = [], 0
for m in google_methods(google_discovery("tagmanager", "v2", refresh)):
e = google_entry(
provider,
m,
platform="google-tag-manager",
granted=granted,
cost=FREE_QUOTA,
docs_url="https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2",
)
google_flat_path_params(e)
if _route_key(e["method"], e["path"]) in skip:
continue
gaps += bool(e.get("scope_gap"))
endpoints.append(e)
notes = [
"",
"ONE HOST. Every path is relative to https://tagmanager.googleapis.com, the OAuth",
"provider's base_url. treg's cumulative read/write/manage capabilities grant readonly,",
"edit.containers, edit.containerversions, and publish respectively.",
"",
"Methods that require tagmanager.delete.containers, tagmanager.manage.users, or",
"tagmanager.manage.accounts remain listed with `scope_gap:` for discoverability, but are",
"intentionally unavailable: treg does not request destructive container deletion or account",
"administration access.",
]
return write_extended(provider, _oauth_source(
"google discovery document",
["https://tagmanager.googleapis.com/$discovery/rest?version=v2"],
), endpoints, notes), {"scope_gaps": gaps}
# --- google-business-profile -------------------------------------------------------------------
# Google retired the single "My Business API v4" into SIX narrow services, each on its own host.
# base_url is the account-management one, so only that family is callable through the provisioned
@@ -2000,6 +2080,7 @@ def ingest_meta(service: str, refresh: bool) -> tuple[Path, dict]:
INGESTERS.update({
"google-search-console": ingest_google_search_console,
"google-analytics": ingest_google_analytics,
"google-tag-manager": ingest_google_tag_manager,
"google-business-profile": ingest_google_business_profile,
"youtube": ingest_youtube,
"google-ads": ingest_google_ads,
+9
View File
@@ -88,6 +88,7 @@ platforms:
# --- Advertising: ad platforms & creator marketplaces ----------------------------------------
google-ads: {label: "Google Ads", category: "Advertising", featured: 1, summary: "Query your own Google Ads accounts with GAQL, and manage campaign budgets."}
meta-ads: {label: "Meta Ads (Facebook & Instagram)", category: "Advertising", featured: 2, summary: "Your Meta ad accounts, campaigns and insights across Facebook and Instagram."}
google-tag-manager: {label: "Google Tag Manager", category: "Advertising", featured: 3, summary: "Audit and manage tags, triggers, variables, workspaces and published container versions in your own GTM account."}
douyin-xingtu: {label: "Xingtu — Douyin's creator marketplace (influencer rates, audience data)", category: "Advertising", summary: "Influencer rates, audience breakdowns and campaign performance."}
xiaohongshu-pugongying: {label: "Pugongying — Xiaohongshu's creator marketplace", category: "Advertising", summary: "Creator pricing, audience and note performance from Xiaohongshu's creator marketplace."}
qq-huxuan: {label: "QQ Huxuan — Tencent's creator marketplace", category: "Advertising", summary: "Creator and placement data from Tencent's Huxuan marketplace."}
@@ -175,6 +176,14 @@ capabilities:
google-analytics.metadata: "List available dimensions & metrics"
google-analytics.realtime: "Realtime visitors on your site"
google-analytics.report: "Run a traffic or behaviour report"
google-tag-manager.accounts: "List the Tag Manager accounts you can access"
google-tag-manager.containers: "List containers in an account"
google-tag-manager.live_version: "Inspect a container's live version"
google-tag-manager.tags: "List tags in a workspace"
google-tag-manager.version.create: "Create a version from a workspace"
google-tag-manager.version.publish: "Publish a container version"
google-tag-manager.workspace.sync: "Sync a workspace with the latest container version"
google-tag-manager.workspaces: "List workspaces in a container"
google-business.accounts: "List the accounts you manage"
google-business.locations: "Your listings with hours & categories"
google-business.review.reply: "Reply to a customer review"
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
# unverified — endpoints require a connected OAuth account; verify via a treg connection later.
#
# The OAuth capabilities are deliberately cumulative:
# read → tagmanager.readonly
# write → read + tagmanager.edit.containers
# manage → write + tagmanager.edit.containerversions + tagmanager.publish
#
# treg intentionally does not request tagmanager.delete.containers, tagmanager.manage.users, or
# tagmanager.manage.accounts. A connected agent can inspect configuration, prepare changes in a
# workspace, create versions, and publish or roll back by publishing an earlier version, but it
# cannot delete whole containers or administer account/container access.
provider: google-tag-manager
source:
docs: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2
openapi: https://tagmanager.googleapis.com/$discovery/rest?version=v2
curated: 2026-08-27
limits: "Google Tag Manager API project and per-user quotas apply"
pricing_url: https://developers.google.com/tag-platform/tag-manager/api/v2/limits-quotas
endpoints:
- id: google-tag-manager.accounts
capability: google-tag-manager.accounts
platform: google-tag-manager
scope: own_account
method: GET
path: /tagmanager/v2/accounts
name: "List your Tag Manager accounts"
summary: "List the Google Tag Manager accounts you can access"
input:
queryParams:
pageToken: {type: string, required: false}
note: "Returns {account: [{path, accountId, name, shareData, fingerprint}]}; `path` (for example accounts/123456) is the resource id used by container calls. This is also the connection health probe and account picker"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; billed against the API's project and per-user quota"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts/list
- id: google-tag-manager.containers
capability: google-tag-manager.containers
platform: google-tag-manager
scope: own_account
method: GET
path: /tagmanager/v2/accounts/{account_id}/containers
name: "List containers in a Tag Manager account"
summary: "List the web, server, Android and iOS containers in an account"
input:
pathParams:
account_id: {type: string, required: true, note: "digits from the account resource path (accounts/123456)", example: "123456"}
queryParams:
pageToken: {type: string, required: false}
note: "Use each container's `path` in the workspace and live-version calls"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers/list
- id: google-tag-manager.workspaces
capability: google-tag-manager.workspaces
platform: google-tag-manager
scope: own_account
method: GET
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces
name: "List workspaces in a container"
summary: "List the draft workspaces available in a Tag Manager container"
input:
pathParams:
account_id: {type: string, required: true, note: "digits from the account resource path", example: "123456"}
container_id: {type: string, required: true, note: "digits from the container resource path", example: "789"}
queryParams:
pageToken: {type: string, required: false}
note: "A workspace isolates draft tags, triggers and variables before a version is created"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces/list
- id: google-tag-manager.tags
capability: google-tag-manager.tags
platform: google-tag-manager
scope: own_account
method: GET
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces/{workspace_id}/tags
name: "List tags in a workspace"
summary: "Inspect the tags configured in a Tag Manager workspace"
input:
pathParams:
account_id: {type: string, required: true, example: "123456"}
container_id: {type: string, required: true, example: "789"}
workspace_id: {type: string, required: true, note: "digits from the workspace resource path", example: "1"}
queryParams:
pageToken: {type: string, required: false}
note: "Tag parameters are schema-driven and vary by tag type; read triggers and variables as related workspace resources before proposing changes"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces.tags/list
- id: google-tag-manager.live-version
capability: google-tag-manager.live_version
platform: google-tag-manager
scope: own_account
method: GET
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/versions:live
name: "Get a container's live version"
summary: "Inspect the configuration currently published from a Tag Manager container"
input:
pathParams:
account_id: {type: string, required: true, example: "123456"}
container_id: {type: string, required: true, note: "digits from the container resource path", example: "789"}
note: "Compare the returned live version with a workspace before creating or publishing a replacement"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.versions/live
- id: google-tag-manager.workspace-sync
kind: action
capability: google-tag-manager.workspace.sync
platform: google-tag-manager
scope: own_account
method: POST
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces/{workspace_id}:sync
name: "Sync a workspace"
summary: "Merge the latest container version into a draft workspace and report conflicts"
input:
pathParams:
account_id: {type: string, required: true, example: "123456"}
container_id: {type: string, required: true, example: "789"}
workspace_id: {type: string, required: true, note: "digits from the workspace resource path", example: "1"}
note: "This changes the draft workspace when upstream changes can be merged; inspect mergeConflict[] before continuing"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces/sync
- id: google-tag-manager.version-create
kind: action
capability: google-tag-manager.version.create
platform: google-tag-manager
scope: own_account
method: POST
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces/{workspace_id}:create_version
name: "Create a container version from a workspace"
summary: "Snapshot a validated draft workspace as a new container version"
input:
pathParams:
account_id: {type: string, required: true, example: "123456"}
container_id: {type: string, required: true, example: "789"}
workspace_id: {type: string, required: true, note: "digits from the workspace resource path", example: "1"}
body:
name: {type: string, required: false, example: "Add GA4 purchase event"}
notes: {type: string, required: false, example: "Reviewed in workspace 1 before publishing"}
bodyType: json
note: "Requires the manage capability. Creating a version does not publish it; inspect compilerError and the returned containerVersion before publishing"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces/create_version
- id: google-tag-manager.version-publish
kind: action
capability: google-tag-manager.version.publish
platform: google-tag-manager
scope: own_account
method: POST
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/versions/{container_version_id}:publish
name: "Publish a container version"
summary: "Make a selected Tag Manager container version live"
input:
pathParams:
account_id: {type: string, required: true, example: "123456"}
container_id: {type: string, required: true, example: "789"}
container_version_id: {type: string, required: true, note: "digits from the container-version resource path", example: "4"}
queryParams:
fingerprint: {type: string, required: false, note: "optimistic-lock fingerprint returned with the version"}
note: "Requires the manage capability and changes the live site/app configuration. Confirm the exact version and inspect compilerError before publishing. Rollback is performed by publishing an earlier version"
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.versions/publish
+67 -3
View File
@@ -322,8 +322,10 @@ class OAuthProvider:
# ---- the registry ------------------------------------------------------------------------
# One Google OAuth client covers Search Console, Analytics, Ads and Business Profile — but each is
# registered separately so a connect only ever requests its own capability's scopes.
# One shared Google OAuth client covers Search Console, Analytics, Business Profile, Tag Manager
# and YouTube — but each is registered separately so a connect only ever requests its own
# capability's scopes. Google Ads is the exception: its developer token is welded to a dedicated
# Cloud project, so GOOGLE_ADS below names the separate google_ads_client_id pair.
GOOGLE_SEARCH_CONSOLE = OAuthProvider(
service="google-search-console",
@@ -452,6 +454,58 @@ GOOGLE_BUSINESS_PROFILE = OAuthProvider(
discover_label_field="accountName",
)
_GTM_READ = [
"https://www.googleapis.com/auth/tagmanager.readonly",
]
_GTM_WRITE = [
*_GTM_READ,
"https://www.googleapis.com/auth/tagmanager.edit.containers",
]
GOOGLE_TAG_MANAGER = OAuthProvider(
service="google-tag-manager",
display_name="Google Tag Manager",
auth_uri="https://accounts.google.com/o/oauth2/v2/auth",
token_uri="https://oauth2.googleapis.com/token",
# Three honest tiers mirror GTM's release workflow. `write` can prepare changes in a workspace
# but cannot turn them into a live release; `manage` adds version creation and publishing.
# Deliberately absent: delete.containers, manage.users and manage.accounts. An agent that can
# configure and publish tags does not also need authority to erase the whole container, change
# who can access it, or administer the account.
scopes={
"read": _GTM_READ,
"write": _GTM_WRITE,
"manage": [
*_GTM_WRITE,
"https://www.googleapis.com/auth/tagmanager.edit.containerversions",
"https://www.googleapis.com/auth/tagmanager.publish",
],
},
client_id_setting="google_client_id",
client_secret_setting="google_client_secret",
category="Advertising",
summary=(
"Audit tags, triggers and variables, prepare changes in a workspace, and publish a reviewed container version."
),
base_url="https://tagmanager.googleapis.com",
docs_url="https://developers.google.com/tag-platform/tag-manager/api/v2",
examples=(
{"method": "GET", "path": "tagmanager/v2/accounts",
"note": "Every GTM account this Google user can access. Choose an account, then list its containers."},
),
resource_label="account",
probe_path="/tagmanager/v2/accounts",
discover_path="/tagmanager/v2/accounts",
discover_key="account",
discover_id_field="path",
discover_label_field="name",
resource_example={
"method": "GET", "path": "tagmanager/v2/{resource}/containers",
"note": "List the GTM containers in your selected account “{resource_name}”. Choose a container "
"before listing its workspaces, tags, triggers or variables.",
},
)
GOOGLE_ADS = OAuthProvider(
service="google-ads",
display_name="Google Ads",
@@ -2344,7 +2398,8 @@ PINTEREST_ADS = OAuthProvider(
REGISTRY: dict[str, OAuthProvider] = {
p.service: p
for p in (
GOOGLE_SEARCH_CONSOLE, GOOGLE_ANALYTICS, GOOGLE_BUSINESS_PROFILE, GOOGLE_ADS, YOUTUBE,
GOOGLE_SEARCH_CONSOLE, GOOGLE_ANALYTICS, GOOGLE_BUSINESS_PROFILE, GOOGLE_TAG_MANAGER,
GOOGLE_ADS, YOUTUBE,
LINKEDIN, SLACK, X, TIKTOK, FACEBOOK, INSTAGRAM, META_ADS,
# API-key providers
APOLLO, PDL, AKTA, HUNTER, CRUNCHBASE, TIKHUB, BRIGHTDATA, SEMRUSH, JUSTONEAPI,
@@ -2419,6 +2474,15 @@ SCOPE_LABELS: dict[str, str] = {
"Manage your business listings, reviews and posts",
"https://www.googleapis.com/auth/adwords":
"Read campaigns, spend and performance, and manage campaigns",
# Google — Tag Manager
"https://www.googleapis.com/auth/tagmanager.readonly":
"View your Tag Manager accounts, containers, workspaces and configuration",
"https://www.googleapis.com/auth/tagmanager.edit.containers":
"Create and change tags, triggers, variables and other workspace configuration",
"https://www.googleapis.com/auth/tagmanager.edit.containerversions":
"Create and manage Tag Manager container versions",
"https://www.googleapis.com/auth/tagmanager.publish":
"Publish Tag Manager container versions to your sites and apps",
# Google — YouTube
"https://www.googleapis.com/auth/youtube.readonly":
"See your channel, videos and playlists",
+1
View File
@@ -6096,6 +6096,7 @@ createApp({
'api.intercom.io':'me','api.stripe.com':'balance','api.render.com':'services?limit=1',
'api.vercel.com':'v2/user','app.posthog.com':'api/projects/@current','eu.posthog.com':'api/projects/@current',
'searchconsole.googleapis.com':'webmasters/v3/sites','googleads.googleapis.com':'v25/customers:listAccessibleCustomers',
'tagmanager.googleapis.com':'tagmanager/v2/accounts',
// part=snippet rather than the probe's part=id: same 1 quota unit, but it returns the channel
// title, so the panel shows something a human recognises instead of an opaque UC… id.
'youtube.googleapis.com':'youtube/v3/channels?part=snippet&mine=true',
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Google Tag Manager">
<polygon fill="#8AB4F8" points="150.261818 245.516364 105.825455 202.185455 201.258182 104.730909 247.265455 149.821818"/>
<path fill="#4285F4" d="M150.450909 53.938182 106.174545 8.730909 9.36 104.629091c-12.48 12.48-12.48 32.712727 0 45.207273l95.36 95.985454 45.090909-42.181818-72.654545-76.407273 73.294545-73.294545Z"/>
<path fill="#8AB4F8" d="m246.625455 105.370909-96-96c-12.494546-12.494545-32.756364-12.494545-45.25091 0-12.494545 12.494546-12.494545 32.756364 0 45.250909l96 96c12.494546 12.494546 32.756364 12.494546 45.25091 0 12.494545-12.494545 12.494545-32.756363 0-45.250909Z"/>
<circle fill="#246FDB" cx="127.265455" cy="224.730909" r="31.272727"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Google Tag Manager">
<polygon fill="#8AB4F8" points="150.261818 245.516364 105.825455 202.185455 201.258182 104.730909 247.265455 149.821818"/>
<path fill="#4285F4" d="M150.450909 53.938182 106.174545 8.730909 9.36 104.629091c-12.48 12.48-12.48 32.712727 0 45.207273l95.36 95.985454 45.090909-42.181818-72.654545-76.407273 73.294545-73.294545Z"/>
<path fill="#8AB4F8" d="m246.625455 105.370909-96-96c-12.494546-12.494545-32.756364-12.494545-45.25091 0-12.494545 12.494546-12.494545 32.756364 0 45.250909l96 96c12.494546 12.494546 32.756364 12.494546 45.25091 0 12.494545-12.494545 12.494545-32.756363 0-45.250909Z"/>
<circle fill="#246FDB" cx="127.265455" cy="224.730909" r="31.272727"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+49
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import dataclasses
import json
import re
from httpx import AsyncClient
@@ -552,6 +553,32 @@ def test_call_templates_share_wire_encoding_and_quote_complete_query_arguments()
assert "'phrase=two words'" in line
def test_gtm_catalog_paths_and_declared_parameters_are_the_same_contract():
"""Every GTM catalog command must ask for the atomic ids its path actually substitutes.
Google Discovery describes these as one semantic parent/path resource, but passing
``accounts/…/containers/…`` through one treg placeholder encodes the hierarchy as ``%2F``.
The curated and generated tiers therefore expose the flattened path segments instead.
"""
cat = cs.load()
endpoints = [ep for ep in cat.by_id.values() if ep["provider"] == "google-tag-manager"]
assert endpoints
for ep in endpoints:
placeholders = set(re.findall(r"{([A-Za-z0-9_]+)}", ep.get("path") or ""))
declared = set((((ep.get("input") or {}).get("pathParams")) or {}))
assert placeholders == declared, ep["id"]
core = [ep for ep in endpoints if ep["tier"] == "core"]
for ep in core:
for spec in (((ep.get("input") or {}).get("pathParams")) or {}).values():
assert "/" not in str((spec or {}).get("example") or ""), ep["id"]
line = cs.call_template(cat.by_id["google-tag-manager.workspaces"])
assert "--query account_id=123456" in line
assert "--query container_id=789" in line
assert "parent=" not in line
def test_catalog_validator_rejects_an_unknown_endpoint_array_encoding(tmp_path, capsys):
"""The endpoint encoding declaration is schema, not free-form prose. Exercise the real
validator so deleting its validation block cannot leave a falsely green test suite."""
@@ -911,6 +938,28 @@ def test_the_ingester_puts_a_POST_routes_arguments_in_the_BODY():
assert "queryParams" in no_body and "body" not in no_body
def test_gtm_ingestion_expands_semantic_resource_names_into_atomic_path_ids():
"""The checked-in extended YAML must stay fixed after the next Discovery re-ingest."""
import sys
sys.path.insert(0, "scripts")
from catalog_ingest import google_flat_path_params
entry = {
"path": "/tagmanager/v2/accounts/{accountsId}/containers/{containersId}/workspaces",
"input": {
"pathParams": {
"parent": {"type": "string", "required": True, "note": "container resource path"},
},
"queryParams": {"pageToken": {"type": "string", "required": False}},
},
}
assert google_flat_path_params(entry) is entry
params = entry["input"]["pathParams"]
assert list(params) == ["accountsId", "containersId"]
assert all(spec["required"] for spec in params.values())
assert "pageToken" in entry["input"]["queryParams"]
def test_a_published_spec_outranks_the_OPTIONS_probe():
"""The probe infers a verb from a preflight; the spec is the provider's own contract. When the
spec names exactly one method the spec wins, so a re-ingest inherits an upstream verb change
+16 -1
View File
@@ -24,7 +24,7 @@ from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from httpx import AsyncClient
from treg import api as A, audit
from treg import api as A, audit, catalog_store, oauth_providers
from treg.config import get_settings
from treg.db import session_maker
from treg.models import Org
@@ -177,6 +177,21 @@ def test_path_placeholders_fill_from_query_and_are_consumed():
assert exc.value.status_code == 400 and "siteUrl" in exc.value.detail
def test_gtm_catalog_builds_hierarchy_from_atomic_ids_without_encoded_slashes():
ep = catalog_store.load().by_id["google-tag-manager.workspaces"]
url, consumed = A._marketplace_upstream(
ep,
oauth_providers.GOOGLE_TAG_MANAGER,
{"account_id": "123", "container_id": "456", "pageToken": "next"},
)
assert url == (
"https://tagmanager.googleapis.com/tagmanager/v2/"
"accounts/123/containers/456/workspaces"
)
assert "%2F" not in url
assert consumed == {"account_id", "container_id"}
async def test_deny_rules_cover_marketplace_calls(clients: AsyncClient):
"""Policy is evaluated on the RESOLVED upstream — an endpoint-id call can't dodge a host block."""
await clients.post("/secrets", json={"name": "tikhub", "value": "MKKEY"})
+22 -2
View File
@@ -39,7 +39,7 @@ def _q(payload: dict) -> dict:
# ---- registry shape ----------------------------------------------------------------------
def test_every_provider_is_registered():
assert set(P.REGISTRY) == {
"google-search-console", "google-analytics", "google-business-profile",
"google-search-console", "google-analytics", "google-business-profile", "google-tag-manager",
"google-ads", "youtube", "linkedin", "slack", "x", "tiktok",
"facebook", "instagram", "meta-ads",
# API-key providers (auth_kind="key")
@@ -61,6 +61,7 @@ def test_default_capability_is_the_broadest():
assert P.GOOGLE_SEARCH_CONSOLE.default_capability == "write"
assert P.X.default_capability == "write"
assert P.GOOGLE_ADS.default_capability == "manage" # it has no read-only mode
assert P.GOOGLE_TAG_MANAGER.default_capability == "manage"
for provider in P.REGISTRY.values():
caps = provider.capabilities
if "read" in caps and "write" in caps:
@@ -166,7 +167,8 @@ def test_slack_is_bring_your_own_bot():
async def test_each_provider_uses_its_own_client_credentials(clients: AsyncClient, all_apps):
for service, expected in (("google-search-console", "google-cid"), ("x", "x-cid")):
for service, expected in (("google-search-console", "google-cid"),
("google-tag-manager", "google-cid"), ("x", "x-cid")):
q = _q((await clients.post("/oauth/start", json={"provider": service})).json())
assert q["client_id"] == [expected], service
@@ -183,6 +185,24 @@ def test_satisfied_capabilities_detects_a_scope_gap():
assert set(gsc.satisfied_capabilities(both)) == {"read", "write"}
def test_google_tag_manager_capabilities_are_cumulative_and_exclude_admin():
"""GTM can audit, prepare and publish without authority to delete an entire container or
administer the account's users. Each wider tier must still satisfy every narrower tier."""
gtm = P.GOOGLE_TAG_MANAGER
assert set(gtm.scopes_for("read")) < set(gtm.scopes_for("write")) < set(gtm.scopes_for("manage"))
assert gtm.default_capability == "manage"
requested = {scope for scopes in gtm.scopes.values() for scope in scopes}
assert not {
"https://www.googleapis.com/auth/tagmanager.delete.containers",
"https://www.googleapis.com/auth/tagmanager.manage.users",
"https://www.googleapis.com/auth/tagmanager.manage.accounts",
} & requested
assert gtm.probe_path == gtm.discover_path == "/tagmanager/v2/accounts"
assert gtm.discover_key == "account"
assert gtm.discover_id_field == "path"
assert gtm.discover_label_field == "name"
async def test_unconfigured_providers_are_listed_but_flagged(clients: AsyncClient, monkeypatch):
monkeypatch.setenv("TREG_X_CLIENT_ID", "")
monkeypatch.setenv("TREG_X_CLIENT_SECRET", "")