@topy-ai/maggie 0.7.7 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/README.zh-TW.md +1 -1
- package/bin/maggie.js +2 -1
- package/bundled-contracts/google-integrations/capability-report-v1.json +87 -0
- package/bundled-contracts/maggiedash/README.md +9 -0
- package/bundled-references/google-integrations-runbook.md +133 -0
- package/bundled-skills/README.md +1 -0
- package/bundled-skills/maggie-dash/SKILL.md +19 -2
- package/bundled-skills/maggie-deployment/SKILL.md +6 -0
- package/bundled-skills/maggie-ops/SKILL.md +17 -1
- package/bundled-skills/maggie-project-context/SKILL.md +5 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +6 -0
- package/bundled-templates/maggiedash/section-registry.json +79 -7
- package/bundled-tools/clis/maggie_ops.py +20 -0
- package/bundled-tools/integrations/analytics.md +5 -0
- package/bundled-tools/runtime/google_capabilities.py +249 -0
- package/bundled-tools/runtime/maggie_sections.py +71 -21
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +133 -0
package/README.md
CHANGED
|
@@ -60,6 +60,18 @@ Maggie keeps the existing project foundation and asks for decisions before
|
|
|
60
60
|
shared routes, analytics, or publishing boundaries change. The current
|
|
61
61
|
package ships 18 installable skills and a local-first MaggieDash foundation.
|
|
62
62
|
|
|
63
|
+
For Google integrations, validate a redacted provider matrix before reporting
|
|
64
|
+
access. The command fails closed on unknown scopes, missing Ads prerequisites,
|
|
65
|
+
duplicate provider resources, and unverified edit/publish claims:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
maggie ops google-capabilities --project . \
|
|
69
|
+
--report .maggie/google-capability-input.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Read the shipped [Google integrations runbook](https://github.com/TOPY-AI-LTD/ai-cmo-skills/blob/main/references/google-integrations-runbook.md)
|
|
73
|
+
for the exact scope allowlist and provider-specific preflight rules.
|
|
74
|
+
|
|
63
75
|
For a genuinely empty project, create the host framework first, then bootstrap
|
|
64
76
|
Maggie in this order:
|
|
65
77
|
|
|
@@ -206,8 +218,8 @@ artifact schemas.
|
|
|
206
218
|
Recommended upgrade sequence for the current release:
|
|
207
219
|
|
|
208
220
|
```bash
|
|
209
|
-
npx @topy-ai/maggie@0.7.
|
|
210
|
-
npx @topy-ai/maggie@0.7.
|
|
221
|
+
npx @topy-ai/maggie@0.7.9 update --project . --force
|
|
222
|
+
npx @topy-ai/maggie@0.7.9 cleanup --project .
|
|
211
223
|
```
|
|
212
224
|
|
|
213
225
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -217,7 +229,10 @@ as a command-line argument:
|
|
|
217
229
|
node scripts/publish-npm.mjs --maggie-env-file ../.env
|
|
218
230
|
```
|
|
219
231
|
|
|
220
|
-
The 0.7.
|
|
232
|
+
The 0.7.9 workflow adds nested section-field contracts, renderer-backed
|
|
233
|
+
examples, stable-ID preservation during migration, and disjoint page
|
|
234
|
+
inventory guidance. It retains the shared Google integrations runbook and
|
|
235
|
+
fail-closed provider capability matrix, alongside the installable MaggieDash admin distribution and
|
|
221
236
|
audited CMS operations (`cms revisions`,
|
|
222
237
|
`trash`, `restore`, `schedule`, `publish-due`, `duplicate`, `redirect`, and
|
|
223
238
|
signed `preview`). It also adds a read-only MaggieDash `--diff`/`--dry-run`
|
|
@@ -455,6 +470,7 @@ python3 tools/clis/maggie_design.py rebrand \
|
|
|
455
470
|
| `maggie-feedback` | Collect redacted feedback drafts and explicitly submit them to the NoBlox feedback endpoint |
|
|
456
471
|
| `maggie-auth-reference` | Generate and validate traditional email/password auth with secure server-side sessions |
|
|
457
472
|
| `maggie-blog` | Run a provider-neutral blog lifecycle with stable identity, topics, feeds, settings, and rollback |
|
|
473
|
+
| `maggie-google-capabilities` | Validate redacted Google provider evidence and separate read/report/edit/publish capability states |
|
|
458
474
|
|
|
459
475
|
`maggie-design` can initialize native blog and service UI plans from a local,
|
|
460
476
|
read-only structural reference:
|
package/README.zh-TW.md
CHANGED
package/bin/maggie.js
CHANGED
|
@@ -120,7 +120,8 @@ Usage:
|
|
|
120
120
|
maggie site-audit URL --crawl --baseline FILE
|
|
121
121
|
maggie browser-audit URL --browse PATH --output DIR --required SELECTOR [--sticky SELECTOR]
|
|
122
122
|
maggie verification coverage --contract FILE --evidence FILE
|
|
123
|
-
maggie ops audit|preflight|verify|lockfiles|seed-manifest --project PATH
|
|
123
|
+
maggie ops audit|preflight|verify|lockfiles|seed-manifest|google-capabilities --project PATH
|
|
124
|
+
maggie ops google-capabilities --project PATH --report FILE [--output FILE]
|
|
124
125
|
maggie ops preflight --project PATH --write
|
|
125
126
|
|
|
126
127
|
Examples:
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggie.noblox.app/contracts/google-integrations/capability-report-v1.json",
|
|
4
|
+
"title": "Maggie Google integrations capability report",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "generatedAt", "providerResults", "passed", "mutationsAllowed"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "const": "maggie-google-capability-report.v1" },
|
|
10
|
+
"generatedAt": { "type": "string", "minLength": 1 },
|
|
11
|
+
"providerResults": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"minItems": 1,
|
|
14
|
+
"items": { "$ref": "#/$defs/providerResult" }
|
|
15
|
+
},
|
|
16
|
+
"passed": { "type": "boolean" },
|
|
17
|
+
"mutationsAllowed": { "const": false }
|
|
18
|
+
},
|
|
19
|
+
"$defs": {
|
|
20
|
+
"providerResult": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": false,
|
|
23
|
+
"required": [
|
|
24
|
+
"provider", "resource", "authMode", "scopes", "productRole",
|
|
25
|
+
"evidence", "capabilities", "nextAction"
|
|
26
|
+
],
|
|
27
|
+
"properties": {
|
|
28
|
+
"provider": { "enum": ["gsc", "ga4", "gtm", "google-ads", "firebase"] },
|
|
29
|
+
"resource": { "type": "string", "minLength": 1, "maxLength": 200 },
|
|
30
|
+
"authMode": { "enum": ["desktop-oauth", "service-account-impersonation", "none"] },
|
|
31
|
+
"scopes": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": { "type": "string", "minLength": 1 },
|
|
34
|
+
"uniqueItems": true
|
|
35
|
+
},
|
|
36
|
+
"productRole": { "type": "string", "minLength": 1, "maxLength": 200 },
|
|
37
|
+
"evidence": { "$ref": "#/$defs/evidence" },
|
|
38
|
+
"capabilities": { "$ref": "#/$defs/capabilities" },
|
|
39
|
+
"nextAction": { "type": "string", "minLength": 1, "maxLength": 500 }
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"evidence": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"required": ["verified", "endpoint", "statusCode", "readOnly"],
|
|
46
|
+
"properties": {
|
|
47
|
+
"verified": { "type": "boolean" },
|
|
48
|
+
"endpoint": { "type": "string", "minLength": 1, "maxLength": 500 },
|
|
49
|
+
"statusCode": { "type": "integer", "minimum": 100, "maximum": 599 },
|
|
50
|
+
"readOnly": { "type": "boolean" },
|
|
51
|
+
"writeTest": { "$ref": "#/$defs/writeTest" },
|
|
52
|
+
"adsPrerequisites": { "$ref": "#/$defs/adsPrerequisites" }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"writeTest": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"required": ["explicitConfirmation", "statusCode", "readBack"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"explicitConfirmation": { "const": true },
|
|
61
|
+
"statusCode": { "type": "integer", "minimum": 200, "maximum": 299 },
|
|
62
|
+
"readBack": { "const": true }
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"adsPrerequisites": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"additionalProperties": false,
|
|
68
|
+
"required": ["developerTokenPresent", "customerIdPresent"],
|
|
69
|
+
"properties": {
|
|
70
|
+
"developerTokenPresent": { "type": "boolean" },
|
|
71
|
+
"customerIdPresent": { "type": "boolean" },
|
|
72
|
+
"loginCustomerIdPresent": { "type": "boolean" }
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"capabilities": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"additionalProperties": false,
|
|
78
|
+
"required": ["read", "report", "edit", "publish"],
|
|
79
|
+
"properties": {
|
|
80
|
+
"read": { "enum": ["verified", "not_tested", "not_available", "blocked"] },
|
|
81
|
+
"report": { "enum": ["verified", "not_tested", "not_available", "blocked"] },
|
|
82
|
+
"edit": { "enum": ["verified", "not_tested", "not_available", "blocked"] },
|
|
83
|
+
"publish": { "enum": ["verified", "not_tested", "not_available", "blocked"] }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -25,6 +25,15 @@ frontend framework.
|
|
|
25
25
|
- [`translation-cache-policy-v1.json`](translation-cache-policy-v1.json):
|
|
26
26
|
restart-after-out-of-band-write evidence.
|
|
27
27
|
|
|
28
|
+
The section registry is an adapter input rather than a fixed six-band list.
|
|
29
|
+
Each registry entry must describe its purpose and limits, include a
|
|
30
|
+
renderer-compatible `example`, and describe object-shaped repeated fields
|
|
31
|
+
under `repeats.of`. Host-specific bands are valid when the host supplies the
|
|
32
|
+
matching renderer and a preview at its real supported breakpoints. Template
|
|
33
|
+
inventory adapters must count published pages once using disjoint page
|
|
34
|
+
classes; child records such as service price options and saved arrangements
|
|
35
|
+
must not be reported as pages.
|
|
36
|
+
|
|
28
37
|
MaggieDash owns these contracts. Provider adapters may add namespaced metadata,
|
|
29
38
|
but they may not change the required identity, status, provenance, or approval
|
|
30
39
|
fields. Unknown fields must be preserved or reported as unsupported during
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Google integrations runbook
|
|
2
|
+
|
|
3
|
+
This is the shared authorization and capability contract for Google Search
|
|
4
|
+
Console (GSC), Google Analytics 4 (GA4), Google Tag Manager (GTM), Google Ads,
|
|
5
|
+
and Firebase. Analytics, Ops, SEO/GEO, deployment, and project-context skills
|
|
6
|
+
must link here instead of inventing provider-specific OAuth guidance.
|
|
7
|
+
|
|
8
|
+
The runbook is deliberately read-first and fail-closed. A successful account
|
|
9
|
+
listing is not proof of reporting access, editing access, or publishing access.
|
|
10
|
+
Never print, persist, or put an access token, refresh token, private key, or
|
|
11
|
+
credential file in a prompt, `.env`, screenshot, feedback payload, or report.
|
|
12
|
+
|
|
13
|
+
## Choose the authentication mode
|
|
14
|
+
|
|
15
|
+
| Mode | Use when | Boundary |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Desktop OAuth | A person is interactively connecting a property or account | Request only the exact scopes needed for the selected read/report task; keep the resulting credential in the local credential store. |
|
|
18
|
+
| Service-account impersonation | A scheduled or server-side workflow needs repeatable access | The caller must have `roles/iam.serviceAccountTokenCreator` on the target service account; the target service account receives product-level access separately. |
|
|
19
|
+
| None | The user has not authorized a provider | Report `blocked` or `not_tested`; do not guess from project IDs or account listings. |
|
|
20
|
+
|
|
21
|
+
For ADC-based local checks, use a short-lived token only in the command
|
|
22
|
+
pipeline. When a non-Cloud API scope is needed, validate the scope first and
|
|
23
|
+
use the supported ADC command shape:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
gcloud auth application-default print-access-token \
|
|
27
|
+
--impersonate-service-account=SERVICE_ACCOUNT \
|
|
28
|
+
--scopes=EXACT_SCOPE
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Do not use `gcloud auth print-access-token` as a substitute for an ADC flow
|
|
32
|
+
when the requested API scope matters. Do not add `--no-launch-browser` to an
|
|
33
|
+
ADC command unless the installed gcloud version explicitly supports that flag;
|
|
34
|
+
use `--no-browser` for the current ADC login flow when a headless device is
|
|
35
|
+
required.
|
|
36
|
+
|
|
37
|
+
## Exact scope allowlist
|
|
38
|
+
|
|
39
|
+
Only the following scopes may appear in a Maggie capability report. A scope
|
|
40
|
+
that is not listed is rejected, including whitespace-split or guessed Tag
|
|
41
|
+
Manager scopes.
|
|
42
|
+
|
|
43
|
+
| Provider | Read/report | Edit | Publish or special access |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| GSC | `https://www.googleapis.com/auth/webmasters.readonly` | Not applicable to this read/report contract | Full read/write `https://www.googleapis.com/auth/webmasters` is allowed only for an explicitly confirmed mutation workflow. |
|
|
46
|
+
| GA4 | `https://www.googleapis.com/auth/analytics.readonly` | `https://www.googleapis.com/auth/analytics.edit` | Publishing is not an Analytics Admin capability. |
|
|
47
|
+
| GTM | `https://www.googleapis.com/auth/tagmanager.readonly` | `https://www.googleapis.com/auth/tagmanager.edit.containers` | `https://www.googleapis.com/auth/tagmanager.publish`; version management is `https://www.googleapis.com/auth/tagmanager.edit.containerversions`. |
|
|
48
|
+
| Google Ads | `https://www.googleapis.com/auth/adwords` | Same OAuth scope, plus product prerequisites | Same OAuth scope, plus product prerequisites and explicit mutation/read-back evidence. |
|
|
49
|
+
| Firebase | `https://www.googleapis.com/auth/cloud-platform` | Same scope, plus Firebase/Cloud IAM permissions | Publishing is not a Firebase Management capability in this contract. |
|
|
50
|
+
|
|
51
|
+
The validator accepts the complete provider allowlist, but a capability is
|
|
52
|
+
verified only when its corresponding required scope is present. Scope presence
|
|
53
|
+
alone never proves product access.
|
|
54
|
+
|
|
55
|
+
## Provider setup and read-only preflight
|
|
56
|
+
|
|
57
|
+
1. Select one provider and one resource. Enable only that provider API in the
|
|
58
|
+
Google Cloud project.
|
|
59
|
+
2. Select Desktop OAuth for a human connection or service-account
|
|
60
|
+
impersonation for automation. Record the mode, exact scopes, resource, and
|
|
61
|
+
product role; never record credential values.
|
|
62
|
+
3. Verify product-level access, not just Cloud IAM:
|
|
63
|
+
- **GSC:** the user/service principal can discover the property and run a
|
|
64
|
+
Search Analytics query.
|
|
65
|
+
- **GA4:** the principal can discover the account/property and run a Data
|
|
66
|
+
API report; Admin API discovery and Data API reporting are separate
|
|
67
|
+
checks.
|
|
68
|
+
- **GTM:** the principal can list the target account/container. Treat edit
|
|
69
|
+
and publish as separate, untested capabilities until deliberately tested.
|
|
70
|
+
- **Google Ads:** verify the customer account and include the developer
|
|
71
|
+
token and customer ID prerequisites. A manager-account flow also needs a
|
|
72
|
+
`login-customer-id`; remove hyphens from customer IDs in API headers.
|
|
73
|
+
- **Firebase:** verify the Cloud project and Firebase resource with the
|
|
74
|
+
required IAM permissions. Firebase access is not implied by GA4 access.
|
|
75
|
+
4. Use an HTTPS provider endpoint and capture only status code, endpoint, and
|
|
76
|
+
redacted result metadata. A read/report check must be read-only and return
|
|
77
|
+
a successful 2xx status.
|
|
78
|
+
5. Generate a capability report and validate it before an agent claims access:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
maggie ops google-capabilities \
|
|
82
|
+
--project . \
|
|
83
|
+
--report .maggie/google-capability-input.json \
|
|
84
|
+
--output .maggie/google-capability-report.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The report contract is
|
|
88
|
+
[`capability-report-v1.json`](../bundled-contracts/google-integrations/capability-report-v1.json).
|
|
89
|
+
The CLI writes a normalized result and never copies arbitrary input fields.
|
|
90
|
+
|
|
91
|
+
## Capability matrix
|
|
92
|
+
|
|
93
|
+
Every provider/resource row must expose this shape:
|
|
94
|
+
|
|
95
|
+
| Field | Meaning |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `provider` / `resource` | The Google product and scoped resource being checked |
|
|
98
|
+
| `authMode` | `desktop-oauth`, `service-account-impersonation`, or `none` |
|
|
99
|
+
| `scopes` | Exact allowlisted OAuth scopes, never token values |
|
|
100
|
+
| `productRole` | Role granted in the product, distinct from Cloud IAM |
|
|
101
|
+
| `evidence` | Redacted HTTPS endpoint, HTTP status, verification state, and read-only state |
|
|
102
|
+
| `capabilities.read` | Property/account/resource discovery status |
|
|
103
|
+
| `capabilities.report` | Reporting/data-read status |
|
|
104
|
+
| `capabilities.edit` | Edit status; `verified` requires explicit mutation and read-back evidence |
|
|
105
|
+
| `capabilities.publish` | Publish status; `verified` requires explicit mutation and read-back evidence |
|
|
106
|
+
| `nextAction` | One concrete missing prerequisite or safe follow-up |
|
|
107
|
+
|
|
108
|
+
Use exactly one of `verified`, `not_tested`, `not_available`, or `blocked` for
|
|
109
|
+
each capability. A report should normally say `not_tested` for GTM edit/publish
|
|
110
|
+
and Ads mutations when only read-only checks were authorized. Never infer a
|
|
111
|
+
write capability from a successful list request.
|
|
112
|
+
|
|
113
|
+
## Mutation boundary
|
|
114
|
+
|
|
115
|
+
The shared validator does not execute provider mutations. A provider adapter
|
|
116
|
+
may test an edit or publish operation only after the user explicitly confirms
|
|
117
|
+
the exact resource and operation. It must capture a successful status and
|
|
118
|
+
read-back evidence, then set the capability to `verified`. Otherwise use
|
|
119
|
+
`not_tested` or `blocked`.
|
|
120
|
+
|
|
121
|
+
Google Ads always remains a separate capability check because OAuth scope,
|
|
122
|
+
product account access, developer token, customer ID, and optional manager
|
|
123
|
+
headers are distinct prerequisites. Firebase similarly requires product/IAM
|
|
124
|
+
permissions; a Cloud project or service account alone is insufficient.
|
|
125
|
+
|
|
126
|
+
## Authoritative references
|
|
127
|
+
|
|
128
|
+
- [Search Console API authorization](https://developers.google.com/webmaster-tools/v1/how-tos/authorizing)
|
|
129
|
+
- [Google Analytics Admin API scopes](https://developers.google.com/analytics/devguides/config/admin/v1/rpc/google.analytics.admin.v1beta)
|
|
130
|
+
- [Tag Manager API authorization](https://developers.google.com/tag-platform/tag-manager/api/v2/authorization)
|
|
131
|
+
- [Google Ads authorization and headers](https://developers.google.com/google-ads/api/rest/auth)
|
|
132
|
+
- [Firebase IAM permissions](https://firebase.google.com/docs/projects/iam/permissions)
|
|
133
|
+
- [ADC access-token command](https://cloud.google.com/sdk/gcloud/reference/auth/application-default/print-access-token)
|
package/bundled-skills/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Skills are the agent-facing workflows. They compose with the tools in
|
|
|
13
13
|
| `maggie-template` | Discover, recommend, inspect, and apply a selected Astro homepage template | marketplace catalog, bootstrap state, template `DESIGN.md` |
|
|
14
14
|
| `maggie-design` | Fully clone authorized interior pages, then reconcile them to the homepage header/footer shell | browser MCP, clone planner CLI, completed Maggie homepage |
|
|
15
15
|
| `maggie-ops` | Build, connect, operate, upgrade, and verify the private blog operations dashboard | Ops dashboard contract, authenticated API bridge, host tests |
|
|
16
|
+
| `maggie-google-capabilities` | Validate a redacted Google provider capability matrix without inferring write access | Google integrations runbook, provider evidence |
|
|
16
17
|
| `maggie-deployment` | Deploy and verify a dynamic Maggie blog, Cloudflare-first | Cloudflare Workers, D1, R2, KV, Wrangler |
|
|
17
18
|
| `maggie-project-context` | Sync Project, voice, site and CTA context | project-context CLI/API |
|
|
18
19
|
| `maggie-seo-geo` | Plan, audit, create/rewrite and measure SEO/GEO | visibility, SEO audit, GSC/GA4, content quality |
|
|
@@ -180,9 +180,26 @@ maggie dash sections keys --registry templates/maggiedash/section-registry.json
|
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
The catalogue declares purpose, usage, placement, repeatability and layout
|
|
183
|
-
limits
|
|
183
|
+
limits, renderer-owned examples, and the shape of repeated entries. A repeat
|
|
184
|
+
may contain an object (`title`, `body`, `href`, and so on), not just a count;
|
|
185
|
+
the nested limits are displayed to the planner and checked by `sections
|
|
186
|
+
validate`. Over-limit copy is an editor note; unknown types fail validation.
|
|
187
|
+
The starter registry is provider-neutral and extensible: a host may add its
|
|
188
|
+
own renderer-backed bands, but every added band must have a real-renderer
|
|
189
|
+
preview or an explicitly labelled example fallback at the supported
|
|
190
|
+
breakpoints. Do not describe the vocabulary as a fixed number of bands.
|
|
191
|
+
|
|
184
192
|
Translation keys use stable section IDs, with an array-index fallback only
|
|
185
|
-
until the ordered migration is complete.
|
|
193
|
+
until the ordered migration is complete. Migration preserves unique IDs that
|
|
194
|
+
already belong to the source section list; it generates a new ID only for a
|
|
195
|
+
missing, duplicate, or target-conflicting ID. Never rebuild IDs from array
|
|
196
|
+
position or overwrite existing translation keys.
|
|
197
|
+
|
|
198
|
+
Keep data-owned values out of page copy. For example, a pricing band should
|
|
199
|
+
store a service identifier or slug and let the live service/booking adapter
|
|
200
|
+
render current options and prices. Inventory reports must classify each
|
|
201
|
+
published page once into disjoint groups; price options, arrangements, and
|
|
202
|
+
other child records are counts, not pages.
|
|
186
203
|
|
|
187
204
|
After any script or direct adapter write to translation data, invalidate the
|
|
188
205
|
running process before verification. Record the restart and run the rendering
|
|
@@ -57,6 +57,12 @@ This command never deploys. Production execution still requires the shared
|
|
|
57
57
|
decision loop, explicit confirmation, migration/rollback details, and the
|
|
58
58
|
provider-specific deploy command.
|
|
59
59
|
|
|
60
|
+
When deployment verification depends on Google properties or accounts, use
|
|
61
|
+
the shared [Google integrations runbook](../../references/google-integrations-runbook.md).
|
|
62
|
+
Keep provider auth and capability evidence separate from deployment health;
|
|
63
|
+
never infer Ads, GTM, GSC, GA4, or Firebase write access from a successful
|
|
64
|
+
read-only check.
|
|
65
|
+
|
|
60
66
|
Generate a reviewable VPS plan and configuration artifacts locally:
|
|
61
67
|
|
|
62
68
|
```bash
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-ops
|
|
3
3
|
description: Build, connect, operate, upgrade, and verify a private Maggie Ops dashboard for blog content, API Pull, sitemap matching, rewrite approvals, reports, and integrations. Use when the user asks for Ops/admin functionality or lifecycle operations rather than public blog pages.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Ops
|
|
@@ -56,6 +56,22 @@ If the user does not name a mode, inspect the project and propose the smallest
|
|
|
56
56
|
mode that satisfies the request. Do not rebuild the public blog or change its
|
|
57
57
|
framework just to add Ops.
|
|
58
58
|
|
|
59
|
+
## Google integrations
|
|
60
|
+
|
|
61
|
+
Use the shared [Google integrations runbook](../../references/google-integrations-runbook.md)
|
|
62
|
+
for GSC, GA4, GTM, Google Ads, and Firebase authorization. Do not invent
|
|
63
|
+
scopes or treat an account listing as proof of reporting, edit, or publish
|
|
64
|
+
access. After a redacted provider preflight, validate the capability matrix:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
maggie ops google-capabilities --project . \
|
|
68
|
+
--report .maggie/google-capability-input.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The result separates `read`, `report`, `edit`, and `publish` for each
|
|
72
|
+
provider/resource. Write and publish remain `not_tested` until the operator
|
|
73
|
+
explicitly confirms the exact mutation and a successful read-back is captured.
|
|
74
|
+
|
|
59
75
|
## Required preflight
|
|
60
76
|
|
|
61
77
|
1. Inspect `.maggie/analysis.json` and `.maggie/bootstrap-state.json`.
|
|
@@ -53,6 +53,11 @@ Do not export team, uploads, testimonials, market research, internal workflow
|
|
|
53
53
|
state, API keys, or OAuth tokens into public source-of-truth files. Missing or
|
|
54
54
|
stale context blocks automatic publishing and falls back to a preview.
|
|
55
55
|
|
|
56
|
+
For Google provider connections used by context or reporting workflows, follow
|
|
57
|
+
the shared [Google integrations runbook](../../references/google-integrations-runbook.md)
|
|
58
|
+
and keep the normalized capability report separate from generated public
|
|
59
|
+
context. Product roles, OAuth scopes, and Cloud IAM are distinct evidence.
|
|
60
|
+
|
|
56
61
|
For the combined API Pull lifecycle, use the repository CLI in dry-run first:
|
|
57
62
|
|
|
58
63
|
```bash
|
|
@@ -112,6 +112,12 @@ Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and c
|
|
|
112
112
|
|
|
113
113
|
For state-changing audits, rewrites, or publishing, follow the shared [Maggie decision loop](../../references/decision-loop.md) and confirm policy and scope before external writes.
|
|
114
114
|
|
|
115
|
+
For GSC/GA4 verification or any Google provider preflight, use the shared
|
|
116
|
+
[Google integrations runbook](../../references/google-integrations-runbook.md).
|
|
117
|
+
Report the verified property, endpoint, date window, and capability state;
|
|
118
|
+
account discovery alone is not evidence of Search Analytics or GA4 reporting
|
|
119
|
+
access, and read access never implies edit or publish access.
|
|
120
|
+
|
|
115
121
|
Treat SEO and GEO as one measurable content system: people-first content,
|
|
116
122
|
technical accessibility, extractable structure, evidence, authority, brand
|
|
117
123
|
voice, CTA alignment, and observed search/AI outcomes.
|
|
@@ -1,12 +1,84 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "maggiedash-section-registry.v1",
|
|
3
|
-
"description": "The
|
|
3
|
+
"description": "The provider-neutral starter vocabulary used by page planners and editors. Hosts may extend it with renderer-backed section types.",
|
|
4
4
|
"sections": [
|
|
5
|
-
{
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
{
|
|
6
|
+
"type": "hero",
|
|
7
|
+
"purpose": "Says what the page is about and offers the one important action.",
|
|
8
|
+
"usage": "First on the page; once.",
|
|
9
|
+
"position": "opening",
|
|
10
|
+
"repeatable": false,
|
|
11
|
+
"fields": [
|
|
12
|
+
{"name": "title", "usage": "Plain words a visitor would search for.", "limit": 70, "required": true},
|
|
13
|
+
{"name": "intro", "usage": "Who it is for and what it does.", "limit": 220},
|
|
14
|
+
{"name": "image", "usage": "The hero image rendered beside or behind the copy.", "limit": 500},
|
|
15
|
+
{"name": "imageAlt", "usage": "What the hero image shows for a reader who cannot see it.", "limit": 160},
|
|
16
|
+
{"name": "ctaLabel", "usage": "The action as a verb.", "limit": 30}
|
|
17
|
+
],
|
|
18
|
+
"example": {"type": "hero", "title": "A clearer way to plan your next step", "intro": "Useful guidance for people who want to move forward with confidence.", "image": "https://images.unsplash.com/photo-1497366754035-f200968a6e72", "imageAlt": "A bright workspace with a table and plants", "ctaLabel": "Get started"}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "prose",
|
|
22
|
+
"purpose": "Explains a topic at length, optionally beside an image.",
|
|
23
|
+
"usage": "Use in the body for explanation.",
|
|
24
|
+
"position": "body",
|
|
25
|
+
"repeatable": true,
|
|
26
|
+
"fields": [
|
|
27
|
+
{"name": "heading", "usage": "The argument in a sentence.", "limit": 90},
|
|
28
|
+
{"name": "paragraphs", "usage": "Standalone paragraphs.", "limit": 0, "repeats": {"min": 1, "max": 4, "of": [{"name": "paragraph", "usage": "One paragraph of plain prose.", "limit": 400}]}},
|
|
29
|
+
{"name": "image", "usage": "An optional supporting image.", "limit": 500},
|
|
30
|
+
{"name": "imageAlt", "usage": "What the supporting image shows.", "limit": 160}
|
|
31
|
+
],
|
|
32
|
+
"example": {"type": "prose", "heading": "Make the important part easier to understand", "paragraphs": ["Start with the decision your reader is trying to make.", "Then give them the context, evidence and next step in that order."]}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"type": "cards",
|
|
36
|
+
"purpose": "Presents parallel points side by side.",
|
|
37
|
+
"usage": "Use for steps or genuinely parallel points.",
|
|
38
|
+
"position": "body",
|
|
39
|
+
"repeatable": true,
|
|
40
|
+
"fields": [
|
|
41
|
+
{"name": "heading", "usage": "What the cards share.", "limit": 90},
|
|
42
|
+
{"name": "items", "usage": "The parallel points.", "limit": 0, "repeats": {"min": 3, "max": 3, "of": [{"name": "title", "usage": "The point in a few words.", "limit": 60, "required": true}, {"name": "body", "usage": "One or two sentences.", "limit": 220}]}}
|
|
43
|
+
],
|
|
44
|
+
"example": {"type": "cards", "heading": "A simple path from question to action", "items": [{"title": "Understand", "body": "See the essential context in one place."}, {"title": "Choose", "body": "Compare the options that fit your situation."}, {"title": "Act", "body": "Take the next step with a clear expectation."}]}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"type": "links",
|
|
48
|
+
"purpose": "Sends the reader to related pages.",
|
|
49
|
+
"usage": "Near the end after the page has done its job.",
|
|
50
|
+
"position": "closing",
|
|
51
|
+
"repeatable": true,
|
|
52
|
+
"fields": [
|
|
53
|
+
{"name": "heading", "usage": "What the links have in common.", "limit": 90},
|
|
54
|
+
{"name": "items", "usage": "Real related pages on this site.", "limit": 0, "repeats": {"min": 2, "max": 6, "of": [{"name": "label", "usage": "The destination page name.", "limit": 60, "required": true}, {"name": "href", "usage": "A real path on this site.", "limit": 200, "required": true}, {"name": "body", "usage": "One sentence on what is there.", "limit": 160}]}}
|
|
55
|
+
],
|
|
56
|
+
"example": {"type": "links", "heading": "Keep exploring", "items": [{"label": "How it works", "href": "/how-it-works/", "body": "See the process from start to finish."}, {"label": "Frequently asked questions", "href": "/faq/", "body": "Find concise answers to common questions."}]}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "faq",
|
|
60
|
+
"purpose": "Answers questions a visitor would otherwise ask.",
|
|
61
|
+
"usage": "Once near the end; use real visitor questions.",
|
|
62
|
+
"position": "closing",
|
|
63
|
+
"repeatable": false,
|
|
64
|
+
"fields": [
|
|
65
|
+
{"name": "heading", "usage": "The question topic.", "limit": 90},
|
|
66
|
+
{"name": "items", "usage": "Direct questions and answers.", "limit": 0, "repeats": {"min": 2, "max": 8, "of": [{"name": "question", "usage": "A question a real visitor asks.", "limit": 140, "required": true}, {"name": "answer", "usage": "A direct, useful answer.", "limit": 600, "required": true}]}}
|
|
67
|
+
],
|
|
68
|
+
"example": {"type": "faq", "heading": "Questions, answered", "items": [{"question": "What happens next?", "answer": "You will see the relevant options and can choose the next step."}, {"question": "Can I ask for help?", "answer": "Yes. Use the contact route and include the decision you are making."}]}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"type": "cta",
|
|
72
|
+
"purpose": "Makes the closing ask.",
|
|
73
|
+
"usage": "Last on the page; once.",
|
|
74
|
+
"position": "closing",
|
|
75
|
+
"repeatable": false,
|
|
76
|
+
"fields": [
|
|
77
|
+
{"name": "heading", "usage": "The invitation in a sentence.", "limit": 90, "required": true},
|
|
78
|
+
{"name": "body", "usage": "What happens next.", "limit": 220},
|
|
79
|
+
{"name": "label", "usage": "The action as a verb.", "limit": 30, "required": true}
|
|
80
|
+
],
|
|
81
|
+
"example": {"type": "cta", "heading": "Ready to take the next step?", "body": "Start with the option that best matches your goal.", "label": "Get started"}
|
|
82
|
+
}
|
|
11
83
|
]
|
|
12
84
|
}
|
|
@@ -14,6 +14,7 @@ from dependency_lock import audit as audit_lockfiles
|
|
|
14
14
|
from seed_evidence import validate_manifest
|
|
15
15
|
from agent_runtime import preflight as agent_preflight
|
|
16
16
|
from restart_gate import validate as validate_restart_gate
|
|
17
|
+
from google_capabilities import validate_report as validate_google_capability_report
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
REQUIRED_ROUTES = (
|
|
@@ -165,6 +166,21 @@ def command_restart_gate(args: argparse.Namespace) -> int:
|
|
|
165
166
|
return 0 if result["passed"] else 1
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
def command_google_capabilities(args: argparse.Namespace) -> int:
|
|
170
|
+
report = read_json(Path(args.report).resolve())
|
|
171
|
+
result = validate_google_capability_report(report)
|
|
172
|
+
output = Path(args.output).resolve() if args.output else root(args) / ".maggie" / "google-capability-report.json"
|
|
173
|
+
write_json(output, result)
|
|
174
|
+
print(json.dumps({
|
|
175
|
+
"status": "passed" if result["passed"] else "failed",
|
|
176
|
+
"report": str(output),
|
|
177
|
+
"providerCount": len(result["providerResults"]),
|
|
178
|
+
"failedChecks": result["errors"],
|
|
179
|
+
"mutationsAllowed": False,
|
|
180
|
+
}, indent=2, ensure_ascii=False))
|
|
181
|
+
return 0 if result["passed"] else 1
|
|
182
|
+
|
|
183
|
+
|
|
168
184
|
def main() -> int:
|
|
169
185
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
170
186
|
parser.add_argument("--project", default=".")
|
|
@@ -183,6 +199,10 @@ def main() -> int:
|
|
|
183
199
|
restart = sub.add_parser("restart-gate", help="validate restart-after-out-of-band-write evidence")
|
|
184
200
|
restart.add_argument("--manifest", required=True)
|
|
185
201
|
restart.set_defaults(func=command_restart_gate)
|
|
202
|
+
google = sub.add_parser("google-capabilities", help="validate a redacted Google provider capability matrix")
|
|
203
|
+
google.add_argument("--report", required=True)
|
|
204
|
+
google.add_argument("--output")
|
|
205
|
+
google.set_defaults(func=command_google_capabilities)
|
|
186
206
|
record = sub.add_parser("record", help="record an explicitly authorised operation")
|
|
187
207
|
record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
|
|
188
208
|
record.add_argument("--dry-run", action="store_true")
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# GA4 and Google Search Console
|
|
2
2
|
|
|
3
|
+
Use the shared [Google integrations runbook](../../references/google-integrations-runbook.md)
|
|
4
|
+
for authorization, exact scopes, product-level roles, and capability
|
|
5
|
+
verification. This guide covers analytics instrumentation; it does not replace
|
|
6
|
+
the provider authentication matrix.
|
|
7
|
+
|
|
3
8
|
## GA4
|
|
4
9
|
|
|
5
10
|
Use the host framework's supported Google tag integration. The minimum
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Fail-closed validation for redacted Google provider capability reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SCHEMA_VERSION = "maggie-google-capability-report.v1"
|
|
11
|
+
CAPABILITY_NAMES = ("read", "report", "edit", "publish")
|
|
12
|
+
CAPABILITY_STATES = {"verified", "not_tested", "not_available", "blocked"}
|
|
13
|
+
PROVIDERS = {"gsc", "ga4", "gtm", "google-ads", "firebase"}
|
|
14
|
+
AUTH_MODES = {"desktop-oauth", "service-account-impersonation", "none"}
|
|
15
|
+
|
|
16
|
+
SUPPORTED_SCOPES = {
|
|
17
|
+
"gsc": {
|
|
18
|
+
"https://www.googleapis.com/auth/webmasters.readonly",
|
|
19
|
+
"https://www.googleapis.com/auth/webmasters",
|
|
20
|
+
},
|
|
21
|
+
"ga4": {
|
|
22
|
+
"https://www.googleapis.com/auth/analytics.readonly",
|
|
23
|
+
"https://www.googleapis.com/auth/analytics.edit",
|
|
24
|
+
},
|
|
25
|
+
"gtm": {
|
|
26
|
+
"https://www.googleapis.com/auth/tagmanager.readonly",
|
|
27
|
+
"https://www.googleapis.com/auth/tagmanager.edit.containers",
|
|
28
|
+
"https://www.googleapis.com/auth/tagmanager.delete.containers",
|
|
29
|
+
"https://www.googleapis.com/auth/tagmanager.edit.containerversions",
|
|
30
|
+
"https://www.googleapis.com/auth/tagmanager.publish",
|
|
31
|
+
"https://www.googleapis.com/auth/tagmanager.manage.users",
|
|
32
|
+
"https://www.googleapis.com/auth/tagmanager.manage.accounts",
|
|
33
|
+
},
|
|
34
|
+
"google-ads": {"https://www.googleapis.com/auth/adwords"},
|
|
35
|
+
"firebase": {"https://www.googleapis.com/auth/cloud-platform"},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
READ_SCOPES = {
|
|
39
|
+
"gsc": "https://www.googleapis.com/auth/webmasters.readonly",
|
|
40
|
+
"ga4": "https://www.googleapis.com/auth/analytics.readonly",
|
|
41
|
+
"gtm": "https://www.googleapis.com/auth/tagmanager.readonly",
|
|
42
|
+
"google-ads": "https://www.googleapis.com/auth/adwords",
|
|
43
|
+
"firebase": "https://www.googleapis.com/auth/cloud-platform",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
EDIT_SCOPES = {
|
|
47
|
+
"gsc": None,
|
|
48
|
+
"ga4": "https://www.googleapis.com/auth/analytics.edit",
|
|
49
|
+
"gtm": "https://www.googleapis.com/auth/tagmanager.edit.containers",
|
|
50
|
+
"google-ads": "https://www.googleapis.com/auth/adwords",
|
|
51
|
+
"firebase": "https://www.googleapis.com/auth/cloud-platform",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
PUBLISH_SCOPES = {
|
|
55
|
+
"gsc": None,
|
|
56
|
+
"ga4": None,
|
|
57
|
+
"gtm": "https://www.googleapis.com/auth/tagmanager.publish",
|
|
58
|
+
"google-ads": "https://www.googleapis.com/auth/adwords",
|
|
59
|
+
"firebase": None,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ALLOWED_ENDPOINT_HOSTS = {
|
|
63
|
+
"searchconsole.googleapis.com",
|
|
64
|
+
"analyticsadmin.googleapis.com",
|
|
65
|
+
"analyticsdata.googleapis.com",
|
|
66
|
+
"tagmanager.googleapis.com",
|
|
67
|
+
"googleads.googleapis.com",
|
|
68
|
+
"firebase.googleapis.com",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
NOT_APPLICABLE = {
|
|
72
|
+
"gsc": {"edit", "publish"},
|
|
73
|
+
"ga4": {"publish"},
|
|
74
|
+
"firebase": {"publish"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _string(value: Any, field: str, errors: list[str], *, max_length: int = 500) -> bool:
|
|
79
|
+
if not isinstance(value, str) or not value.strip() or len(value) > max_length:
|
|
80
|
+
errors.append(f"{field} must be a non-empty bounded string")
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _endpoint(value: Any, field: str, errors: list[str]) -> bool:
|
|
86
|
+
if not _string(value, field, errors):
|
|
87
|
+
return False
|
|
88
|
+
parts = urlsplit(value)
|
|
89
|
+
if parts.scheme != "https" or not parts.hostname or parts.username or parts.password or parts.query or parts.fragment:
|
|
90
|
+
errors.append(f"{field} must be an HTTPS URL without credentials, query, or fragment")
|
|
91
|
+
return False
|
|
92
|
+
if parts.hostname.lower() not in ALLOWED_ENDPOINT_HOSTS:
|
|
93
|
+
errors.append(f"{field} host is not an approved Google API host")
|
|
94
|
+
return False
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_test(value: Any, field: str, errors: list[str]) -> bool:
|
|
99
|
+
if not isinstance(value, dict):
|
|
100
|
+
errors.append(f"{field} is required for a verified write capability")
|
|
101
|
+
return False
|
|
102
|
+
allowed = {"explicitConfirmation", "statusCode", "readBack"}
|
|
103
|
+
unknown = sorted(set(value) - allowed)
|
|
104
|
+
if unknown:
|
|
105
|
+
errors.append(f"{field} contains unsupported fields")
|
|
106
|
+
valid = value.get("explicitConfirmation") is True and value.get("readBack") is True
|
|
107
|
+
if not valid:
|
|
108
|
+
errors.append(f"{field} requires explicitConfirmation and readBack")
|
|
109
|
+
if not isinstance(value.get("statusCode"), int) or not 200 <= value["statusCode"] <= 299:
|
|
110
|
+
errors.append(f"{field}.statusCode must be a successful HTTP status")
|
|
111
|
+
valid = False
|
|
112
|
+
return valid and not unknown
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _required_scope(provider: str, capability: str) -> str | None:
|
|
116
|
+
if capability == "read" or capability == "report":
|
|
117
|
+
return READ_SCOPES[provider]
|
|
118
|
+
if capability == "edit":
|
|
119
|
+
return EDIT_SCOPES[provider]
|
|
120
|
+
return PUBLISH_SCOPES[provider]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_report(report: Any) -> dict[str, Any]:
|
|
124
|
+
"""Return safe validation output; never returns arbitrary input fields."""
|
|
125
|
+
errors: list[str] = []
|
|
126
|
+
if not isinstance(report, dict):
|
|
127
|
+
return {"schemaVersion": "maggie-google-capability-report-result.v1", "passed": False, "errors": ["report must be an object"], "providerResults": []}
|
|
128
|
+
if report.get("schemaVersion") != SCHEMA_VERSION:
|
|
129
|
+
errors.append("schemaVersion must be maggie-google-capability-report.v1")
|
|
130
|
+
if not _string(report.get("generatedAt"), "generatedAt", errors):
|
|
131
|
+
pass
|
|
132
|
+
if report.get("mutationsAllowed") is not False:
|
|
133
|
+
errors.append("mutationsAllowed must be false")
|
|
134
|
+
rows = report.get("providerResults")
|
|
135
|
+
if not isinstance(rows, list) or not rows:
|
|
136
|
+
errors.append("providerResults must be a non-empty array")
|
|
137
|
+
rows = []
|
|
138
|
+
|
|
139
|
+
normalized: list[dict[str, Any]] = []
|
|
140
|
+
seen: set[tuple[str, str]] = set()
|
|
141
|
+
for index, row in enumerate(rows):
|
|
142
|
+
prefix = f"providerResults[{index}]"
|
|
143
|
+
if not isinstance(row, dict):
|
|
144
|
+
errors.append(f"{prefix} must be an object")
|
|
145
|
+
continue
|
|
146
|
+
required = {"provider", "resource", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
|
|
147
|
+
unknown = sorted(set(row) - required)
|
|
148
|
+
if unknown:
|
|
149
|
+
errors.append(f"{prefix} contains unsupported fields")
|
|
150
|
+
provider = row.get("provider")
|
|
151
|
+
resource = row.get("resource")
|
|
152
|
+
if provider not in PROVIDERS:
|
|
153
|
+
errors.append(f"{prefix}.provider is unsupported")
|
|
154
|
+
continue
|
|
155
|
+
if not _string(resource, f"{prefix}.resource", errors, max_length=200):
|
|
156
|
+
continue
|
|
157
|
+
key = (provider, resource)
|
|
158
|
+
if key in seen:
|
|
159
|
+
errors.append(f"{prefix} duplicates provider/resource")
|
|
160
|
+
seen.add(key)
|
|
161
|
+
if row.get("authMode") not in AUTH_MODES:
|
|
162
|
+
errors.append(f"{prefix}.authMode is unsupported")
|
|
163
|
+
scopes = row.get("scopes")
|
|
164
|
+
valid_scope_values = isinstance(scopes, list) and all(isinstance(scope, str) and bool(scope) and not re.search(r"\s", scope) for scope in scopes)
|
|
165
|
+
if not valid_scope_values or len(scopes) != len(set(scopes)):
|
|
166
|
+
errors.append(f"{prefix}.scopes must be a unique array of exact scope strings")
|
|
167
|
+
scopes = []
|
|
168
|
+
unknown_scopes = sorted(set(scopes) - SUPPORTED_SCOPES[provider])
|
|
169
|
+
if unknown_scopes:
|
|
170
|
+
errors.append(f"{prefix}.scopes contains an unsupported scope")
|
|
171
|
+
if not _string(row.get("productRole"), f"{prefix}.productRole", errors, max_length=200):
|
|
172
|
+
pass
|
|
173
|
+
if not _string(row.get("nextAction"), f"{prefix}.nextAction", errors):
|
|
174
|
+
pass
|
|
175
|
+
evidence = row.get("evidence")
|
|
176
|
+
if not isinstance(evidence, dict):
|
|
177
|
+
errors.append(f"{prefix}.evidence must be an object")
|
|
178
|
+
evidence = {}
|
|
179
|
+
else:
|
|
180
|
+
evidence_allowed = {"verified", "endpoint", "statusCode", "readOnly", "writeTest", "adsPrerequisites"}
|
|
181
|
+
if set(evidence) - evidence_allowed:
|
|
182
|
+
errors.append(f"{prefix}.evidence contains unsupported fields")
|
|
183
|
+
verified_evidence = evidence.get("verified") is True
|
|
184
|
+
if not _endpoint(evidence.get("endpoint"), f"{prefix}.evidence.endpoint", errors):
|
|
185
|
+
pass
|
|
186
|
+
status_code = evidence.get("statusCode")
|
|
187
|
+
if not isinstance(status_code, int) or not 100 <= status_code <= 599:
|
|
188
|
+
errors.append(f"{prefix}.evidence.statusCode must be an HTTP status")
|
|
189
|
+
status_code = 0
|
|
190
|
+
if not isinstance(evidence.get("readOnly"), bool):
|
|
191
|
+
errors.append(f"{prefix}.evidence.readOnly must be boolean")
|
|
192
|
+
capabilities = row.get("capabilities")
|
|
193
|
+
if not isinstance(capabilities, dict):
|
|
194
|
+
errors.append(f"{prefix}.capabilities must be an object")
|
|
195
|
+
capabilities = {}
|
|
196
|
+
else:
|
|
197
|
+
if set(capabilities) != set(CAPABILITY_NAMES):
|
|
198
|
+
errors.append(f"{prefix}.capabilities must contain read, report, edit, and publish only")
|
|
199
|
+
safe_capabilities: dict[str, str] = {}
|
|
200
|
+
for capability in CAPABILITY_NAMES:
|
|
201
|
+
state = capabilities.get(capability)
|
|
202
|
+
if state not in CAPABILITY_STATES:
|
|
203
|
+
errors.append(f"{prefix}.capabilities.{capability} is unsupported")
|
|
204
|
+
state = "blocked"
|
|
205
|
+
safe_capabilities[capability] = state
|
|
206
|
+
if capability in NOT_APPLICABLE.get(provider, set()) and state != "not_available":
|
|
207
|
+
errors.append(f"{prefix}.capabilities.{capability} must be not_available for {provider}")
|
|
208
|
+
if state == "verified":
|
|
209
|
+
needed = _required_scope(provider, capability)
|
|
210
|
+
if needed is None:
|
|
211
|
+
errors.append(f"{prefix}.capabilities.{capability} cannot be verified for {provider}")
|
|
212
|
+
elif needed not in scopes:
|
|
213
|
+
errors.append(f"{prefix}.capabilities.{capability} requires its exact provider scope")
|
|
214
|
+
if not verified_evidence or not 200 <= status_code <= 299:
|
|
215
|
+
errors.append(f"{prefix}.capabilities.{capability} requires verified 2xx evidence")
|
|
216
|
+
if capability in {"read", "report"} and evidence.get("readOnly") is not True:
|
|
217
|
+
errors.append(f"{prefix}.capabilities.{capability} requires readOnly evidence")
|
|
218
|
+
if capability in {"edit", "publish"} and not _write_test(evidence.get("writeTest"), f"{prefix}.evidence.writeTest", errors):
|
|
219
|
+
pass
|
|
220
|
+
prerequisites = evidence.get("adsPrerequisites")
|
|
221
|
+
if provider == "google-ads" and any(state == "verified" for state in safe_capabilities.values()):
|
|
222
|
+
if not isinstance(prerequisites, dict) or prerequisites.get("developerTokenPresent") is not True or prerequisites.get("customerIdPresent") is not True:
|
|
223
|
+
errors.append(f"{prefix}.evidence.adsPrerequisites requires developer token and customer ID presence")
|
|
224
|
+
elif prerequisites is not None:
|
|
225
|
+
errors.append(f"{prefix}.evidence.adsPrerequisites is only valid for google-ads")
|
|
226
|
+
normalized.append({
|
|
227
|
+
"provider": provider,
|
|
228
|
+
"resource": resource,
|
|
229
|
+
"authMode": row.get("authMode"),
|
|
230
|
+
"scopes": sorted(scopes),
|
|
231
|
+
"productRole": row.get("productRole"),
|
|
232
|
+
"evidence": {
|
|
233
|
+
"verified": verified_evidence,
|
|
234
|
+
"endpoint": evidence.get("endpoint"),
|
|
235
|
+
"statusCode": status_code,
|
|
236
|
+
"readOnly": evidence.get("readOnly"),
|
|
237
|
+
"writeTested": isinstance(evidence.get("writeTest"), dict),
|
|
238
|
+
},
|
|
239
|
+
"capabilities": safe_capabilities,
|
|
240
|
+
"nextAction": row.get("nextAction"),
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"schemaVersion": "maggie-google-capability-report-result.v1",
|
|
245
|
+
"passed": not errors,
|
|
246
|
+
"errors": sorted(set(errors)),
|
|
247
|
+
"providerResults": normalized,
|
|
248
|
+
"mutationsAllowed": False,
|
|
249
|
+
}
|
|
@@ -14,6 +14,35 @@ SCHEMA = "maggiedash-section-registry.v1"
|
|
|
14
14
|
IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _validate_field(field: object, at: str, errors: list[str], *, nested: bool = False) -> None:
|
|
18
|
+
if not isinstance(field, dict) or not field.get("name"):
|
|
19
|
+
errors.append(f"{at} needs a name")
|
|
20
|
+
return
|
|
21
|
+
if not isinstance(field.get("limit"), int) or field["limit"] < 0:
|
|
22
|
+
errors.append(f"{at}.limit must be a non-negative integer")
|
|
23
|
+
if not str(field.get("usage") or "").strip():
|
|
24
|
+
errors.append(f"{at}.usage is required")
|
|
25
|
+
repeats = field.get("repeats")
|
|
26
|
+
if repeats is None:
|
|
27
|
+
return
|
|
28
|
+
if not isinstance(repeats, dict) or not isinstance(repeats.get("min"), int) or not isinstance(repeats.get("max"), int) or repeats["min"] < 0 or repeats["min"] > repeats["max"]:
|
|
29
|
+
errors.append(f"{at}.repeats must declare valid min/max")
|
|
30
|
+
return
|
|
31
|
+
item_fields = repeats.get("of")
|
|
32
|
+
if not isinstance(item_fields, list) or not item_fields:
|
|
33
|
+
errors.append(f"{at}.repeats.of must be a non-empty field list")
|
|
34
|
+
return
|
|
35
|
+
nested_names: set[str] = set()
|
|
36
|
+
for item_index, item_field in enumerate(item_fields):
|
|
37
|
+
item_at = f"{at}.repeats.of[{item_index}]"
|
|
38
|
+
_validate_field(item_field, item_at, errors, nested=True)
|
|
39
|
+
if isinstance(item_field, dict) and item_field.get("name"):
|
|
40
|
+
name = str(item_field["name"])
|
|
41
|
+
if name in nested_names:
|
|
42
|
+
errors.append(f"duplicate repeated field: {name}")
|
|
43
|
+
nested_names.add(name)
|
|
44
|
+
|
|
45
|
+
|
|
17
46
|
def validate_registry(value: object) -> dict[str, Any]:
|
|
18
47
|
errors: list[str] = []
|
|
19
48
|
if not isinstance(value, dict):
|
|
@@ -57,13 +86,10 @@ def validate_registry(value: object) -> dict[str, Any]:
|
|
|
57
86
|
if name in field_names:
|
|
58
87
|
errors.append(f"duplicate field: {section_type}.{name}")
|
|
59
88
|
field_names.add(name)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
repeats = field.get("repeats")
|
|
65
|
-
if repeats is not None and (not isinstance(repeats, dict) or not isinstance(repeats.get("min"), int) or not isinstance(repeats.get("max"), int) or repeats["min"] < 0 or repeats["min"] > repeats["max"]):
|
|
66
|
-
errors.append(f"{field_at}.repeats must declare valid min/max")
|
|
89
|
+
_validate_field(field, field_at, errors)
|
|
90
|
+
example = section.get("example")
|
|
91
|
+
if not isinstance(example, dict) or example.get("type") != section_type:
|
|
92
|
+
errors.append(f"{at}.example must be an object with type {section_type}")
|
|
67
93
|
return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "sectionTypes": sorted(seen)}
|
|
68
94
|
|
|
69
95
|
|
|
@@ -74,11 +100,39 @@ def catalogue(registry: dict[str, Any]) -> str:
|
|
|
74
100
|
lines.append(f"{section['type']} — {section['purpose']} | when: {section['usage']} | place: {section['position']} | {'repeatable' if section['repeatable'] else 'once'}")
|
|
75
101
|
for field in section.get("fields", []):
|
|
76
102
|
repeat = field.get("repeats")
|
|
77
|
-
suffix =
|
|
103
|
+
suffix = ""
|
|
104
|
+
if isinstance(repeat, dict):
|
|
105
|
+
count = repeat["min"] if repeat["min"] == repeat["max"] else f"{repeat['min']}-{repeat['max']}"
|
|
106
|
+
nested = ", ".join(f"{item['name']} ≤{item['limit']}" for item in repeat.get("of", []) if isinstance(item, dict))
|
|
107
|
+
suffix = f" [{count} entries" + (f" of {{{nested}}}" if nested else "") + "]"
|
|
78
108
|
lines.append(f" - {field['name']} ≤{field['limit']} chars{suffix}: {field['usage']}")
|
|
79
109
|
return "\n".join(lines)
|
|
80
110
|
|
|
81
111
|
|
|
112
|
+
def _copy_notes(value: object, fields: list[dict[str, Any]], path: str, notes: list[dict[str, Any]]) -> None:
|
|
113
|
+
"""Check a field value, including object-shaped repeated entries."""
|
|
114
|
+
for field in fields:
|
|
115
|
+
name = str(field.get("name"))
|
|
116
|
+
child = value.get(name) if isinstance(value, dict) else None
|
|
117
|
+
limit = int(field.get("limit", 0))
|
|
118
|
+
if isinstance(child, str) and limit and len(child) > limit:
|
|
119
|
+
notes.append({"at": f"{path}.{name}", "message": f"{len(child)} characters; layout limit is about {limit}"})
|
|
120
|
+
repeats = field.get("repeats")
|
|
121
|
+
if not isinstance(repeats, dict) or not isinstance(child, list):
|
|
122
|
+
continue
|
|
123
|
+
if not repeats["min"] <= len(child) <= repeats["max"]:
|
|
124
|
+
notes.append({"at": f"{path}.{name}", "message": f"{len(child)} entries; layout expects {repeats['min']}-{repeats['max']}"})
|
|
125
|
+
item_fields = [item for item in repeats.get("of", []) if isinstance(item, dict)]
|
|
126
|
+
for item_index, item in enumerate(child):
|
|
127
|
+
item_path = f"{path}.{name}[{item_index}]"
|
|
128
|
+
if isinstance(item, dict):
|
|
129
|
+
_copy_notes(item, item_fields, item_path, notes)
|
|
130
|
+
elif len(item_fields) == 1 and isinstance(item, str):
|
|
131
|
+
item_limit = int(item_fields[0].get("limit", 0))
|
|
132
|
+
if item_limit and len(item) > item_limit:
|
|
133
|
+
notes.append({"at": item_path, "message": f"{len(item)} characters; layout limit is about {item_limit}"})
|
|
134
|
+
|
|
135
|
+
|
|
82
136
|
def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
|
|
83
137
|
"""Return advisory copy/shape notes; over-limit copy is not rejected."""
|
|
84
138
|
errors: list[str] = []
|
|
@@ -95,15 +149,7 @@ def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
|
|
|
95
149
|
if not spec:
|
|
96
150
|
errors.append(f"unknown section type: {section_type}")
|
|
97
151
|
continue
|
|
98
|
-
for field in spec.get("fields", [])
|
|
99
|
-
name = str(field.get("name"))
|
|
100
|
-
value = section.get(name)
|
|
101
|
-
limit = int(field.get("limit", 0))
|
|
102
|
-
if isinstance(value, str) and limit and len(value) > limit:
|
|
103
|
-
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} characters; layout limit is about {limit}"})
|
|
104
|
-
repeats = field.get("repeats")
|
|
105
|
-
if isinstance(repeats, dict) and isinstance(value, list) and not repeats["min"] <= len(value) <= repeats["max"]:
|
|
106
|
-
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} entries; layout expects {repeats['min']}-{repeats['max']}"})
|
|
152
|
+
_copy_notes(section, [field for field in spec.get("fields", []) if isinstance(field, dict)], f"sections[{index}]", notes)
|
|
107
153
|
return {"passed": not errors, "errors": errors, "notes": notes}
|
|
108
154
|
|
|
109
155
|
|
|
@@ -117,12 +163,16 @@ def new_section_id(existing: Iterable[str] = ()) -> str:
|
|
|
117
163
|
|
|
118
164
|
def ensure_section_ids(sections: list[dict[str, Any]], existing: Iterable[str] = ()) -> list[dict[str, Any]]:
|
|
119
165
|
result = copy.deepcopy(sections)
|
|
120
|
-
|
|
121
|
-
taken = set(
|
|
166
|
+
reserved = {str(item) for item in existing if str(item)}
|
|
167
|
+
taken: set[str] = set()
|
|
122
168
|
for section in result:
|
|
123
169
|
current = str(section.get("id") or "")
|
|
124
|
-
|
|
125
|
-
|
|
170
|
+
# Existing IDs belong to the source section list and must survive a
|
|
171
|
+
# migration. Only absent, duplicated, or target-conflicting IDs get a
|
|
172
|
+
# replacement. The old implementation put current IDs in `taken`
|
|
173
|
+
# before inspecting them and consequently rewrote every section.
|
|
174
|
+
if not current or current in taken or current in reserved:
|
|
175
|
+
section["id"] = new_section_id(taken | reserved)
|
|
126
176
|
taken.add(str(section["id"]))
|
|
127
177
|
return result
|
|
128
178
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Google integrations runbook
|
|
2
|
+
|
|
3
|
+
This is the shared authorization and capability contract for Google Search
|
|
4
|
+
Console (GSC), Google Analytics 4 (GA4), Google Tag Manager (GTM), Google Ads,
|
|
5
|
+
and Firebase. Analytics, Ops, SEO/GEO, deployment, and project-context skills
|
|
6
|
+
must link here instead of inventing provider-specific OAuth guidance.
|
|
7
|
+
|
|
8
|
+
The runbook is deliberately read-first and fail-closed. A successful account
|
|
9
|
+
listing is not proof of reporting access, editing access, or publishing access.
|
|
10
|
+
Never print, persist, or put an access token, refresh token, private key, or
|
|
11
|
+
credential file in a prompt, `.env`, screenshot, feedback payload, or report.
|
|
12
|
+
|
|
13
|
+
## Choose the authentication mode
|
|
14
|
+
|
|
15
|
+
| Mode | Use when | Boundary |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Desktop OAuth | A person is interactively connecting a property or account | Request only the exact scopes needed for the selected read/report task; keep the resulting credential in the local credential store. |
|
|
18
|
+
| Service-account impersonation | A scheduled or server-side workflow needs repeatable access | The caller must have `roles/iam.serviceAccountTokenCreator` on the target service account; the target service account receives product-level access separately. |
|
|
19
|
+
| None | The user has not authorized a provider | Report `blocked` or `not_tested`; do not guess from project IDs or account listings. |
|
|
20
|
+
|
|
21
|
+
For ADC-based local checks, use a short-lived token only in the command
|
|
22
|
+
pipeline. When a non-Cloud API scope is needed, validate the scope first and
|
|
23
|
+
use the supported ADC command shape:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
gcloud auth application-default print-access-token \
|
|
27
|
+
--impersonate-service-account=SERVICE_ACCOUNT \
|
|
28
|
+
--scopes=EXACT_SCOPE
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Do not use `gcloud auth print-access-token` as a substitute for an ADC flow
|
|
32
|
+
when the requested API scope matters. Do not add `--no-launch-browser` to an
|
|
33
|
+
ADC command unless the installed gcloud version explicitly supports that flag;
|
|
34
|
+
use `--no-browser` for the current ADC login flow when a headless device is
|
|
35
|
+
required.
|
|
36
|
+
|
|
37
|
+
## Exact scope allowlist
|
|
38
|
+
|
|
39
|
+
Only the following scopes may appear in a Maggie capability report. A scope
|
|
40
|
+
that is not listed is rejected, including whitespace-split or guessed Tag
|
|
41
|
+
Manager scopes.
|
|
42
|
+
|
|
43
|
+
| Provider | Read/report | Edit | Publish or special access |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| GSC | `https://www.googleapis.com/auth/webmasters.readonly` | Not applicable to this read/report contract | Full read/write `https://www.googleapis.com/auth/webmasters` is allowed only for an explicitly confirmed mutation workflow. |
|
|
46
|
+
| GA4 | `https://www.googleapis.com/auth/analytics.readonly` | `https://www.googleapis.com/auth/analytics.edit` | Publishing is not an Analytics Admin capability. |
|
|
47
|
+
| GTM | `https://www.googleapis.com/auth/tagmanager.readonly` | `https://www.googleapis.com/auth/tagmanager.edit.containers` | `https://www.googleapis.com/auth/tagmanager.publish`; version management is `https://www.googleapis.com/auth/tagmanager.edit.containerversions`. |
|
|
48
|
+
| Google Ads | `https://www.googleapis.com/auth/adwords` | Same OAuth scope, plus product prerequisites | Same OAuth scope, plus product prerequisites and explicit mutation/read-back evidence. |
|
|
49
|
+
| Firebase | `https://www.googleapis.com/auth/cloud-platform` | Same scope, plus Firebase/Cloud IAM permissions | Publishing is not a Firebase Management capability in this contract. |
|
|
50
|
+
|
|
51
|
+
The validator accepts the complete provider allowlist, but a capability is
|
|
52
|
+
verified only when its corresponding required scope is present. Scope presence
|
|
53
|
+
alone never proves product access.
|
|
54
|
+
|
|
55
|
+
## Provider setup and read-only preflight
|
|
56
|
+
|
|
57
|
+
1. Select one provider and one resource. Enable only that provider API in the
|
|
58
|
+
Google Cloud project.
|
|
59
|
+
2. Select Desktop OAuth for a human connection or service-account
|
|
60
|
+
impersonation for automation. Record the mode, exact scopes, resource, and
|
|
61
|
+
product role; never record credential values.
|
|
62
|
+
3. Verify product-level access, not just Cloud IAM:
|
|
63
|
+
- **GSC:** the user/service principal can discover the property and run a
|
|
64
|
+
Search Analytics query.
|
|
65
|
+
- **GA4:** the principal can discover the account/property and run a Data
|
|
66
|
+
API report; Admin API discovery and Data API reporting are separate
|
|
67
|
+
checks.
|
|
68
|
+
- **GTM:** the principal can list the target account/container. Treat edit
|
|
69
|
+
and publish as separate, untested capabilities until deliberately tested.
|
|
70
|
+
- **Google Ads:** verify the customer account and include the developer
|
|
71
|
+
token and customer ID prerequisites. A manager-account flow also needs a
|
|
72
|
+
`login-customer-id`; remove hyphens from customer IDs in API headers.
|
|
73
|
+
- **Firebase:** verify the Cloud project and Firebase resource with the
|
|
74
|
+
required IAM permissions. Firebase access is not implied by GA4 access.
|
|
75
|
+
4. Use an HTTPS provider endpoint and capture only status code, endpoint, and
|
|
76
|
+
redacted result metadata. A read/report check must be read-only and return
|
|
77
|
+
a successful 2xx status.
|
|
78
|
+
5. Generate a capability report and validate it before an agent claims access:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
maggie ops google-capabilities \
|
|
82
|
+
--project . \
|
|
83
|
+
--report .maggie/google-capability-input.json \
|
|
84
|
+
--output .maggie/google-capability-report.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The report contract is
|
|
88
|
+
[`capability-report-v1.json`](../bundled-contracts/google-integrations/capability-report-v1.json).
|
|
89
|
+
The CLI writes a normalized result and never copies arbitrary input fields.
|
|
90
|
+
|
|
91
|
+
## Capability matrix
|
|
92
|
+
|
|
93
|
+
Every provider/resource row must expose this shape:
|
|
94
|
+
|
|
95
|
+
| Field | Meaning |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `provider` / `resource` | The Google product and scoped resource being checked |
|
|
98
|
+
| `authMode` | `desktop-oauth`, `service-account-impersonation`, or `none` |
|
|
99
|
+
| `scopes` | Exact allowlisted OAuth scopes, never token values |
|
|
100
|
+
| `productRole` | Role granted in the product, distinct from Cloud IAM |
|
|
101
|
+
| `evidence` | Redacted HTTPS endpoint, HTTP status, verification state, and read-only state |
|
|
102
|
+
| `capabilities.read` | Property/account/resource discovery status |
|
|
103
|
+
| `capabilities.report` | Reporting/data-read status |
|
|
104
|
+
| `capabilities.edit` | Edit status; `verified` requires explicit mutation and read-back evidence |
|
|
105
|
+
| `capabilities.publish` | Publish status; `verified` requires explicit mutation and read-back evidence |
|
|
106
|
+
| `nextAction` | One concrete missing prerequisite or safe follow-up |
|
|
107
|
+
|
|
108
|
+
Use exactly one of `verified`, `not_tested`, `not_available`, or `blocked` for
|
|
109
|
+
each capability. A report should normally say `not_tested` for GTM edit/publish
|
|
110
|
+
and Ads mutations when only read-only checks were authorized. Never infer a
|
|
111
|
+
write capability from a successful list request.
|
|
112
|
+
|
|
113
|
+
## Mutation boundary
|
|
114
|
+
|
|
115
|
+
The shared validator does not execute provider mutations. A provider adapter
|
|
116
|
+
may test an edit or publish operation only after the user explicitly confirms
|
|
117
|
+
the exact resource and operation. It must capture a successful status and
|
|
118
|
+
read-back evidence, then set the capability to `verified`. Otherwise use
|
|
119
|
+
`not_tested` or `blocked`.
|
|
120
|
+
|
|
121
|
+
Google Ads always remains a separate capability check because OAuth scope,
|
|
122
|
+
product account access, developer token, customer ID, and optional manager
|
|
123
|
+
headers are distinct prerequisites. Firebase similarly requires product/IAM
|
|
124
|
+
permissions; a Cloud project or service account alone is insufficient.
|
|
125
|
+
|
|
126
|
+
## Authoritative references
|
|
127
|
+
|
|
128
|
+
- [Search Console API authorization](https://developers.google.com/webmaster-tools/v1/how-tos/authorizing)
|
|
129
|
+
- [Google Analytics Admin API scopes](https://developers.google.com/analytics/devguides/config/admin/v1/rpc/google.analytics.admin.v1beta)
|
|
130
|
+
- [Tag Manager API authorization](https://developers.google.com/tag-platform/tag-manager/api/v2/authorization)
|
|
131
|
+
- [Google Ads authorization and headers](https://developers.google.com/google-ads/api/rest/auth)
|
|
132
|
+
- [Firebase IAM permissions](https://firebase.google.com/docs/projects/iam/permissions)
|
|
133
|
+
- [ADC access-token command](https://cloud.google.com/sdk/gcloud/reference/auth/application-default/print-access-token)
|