diff --git a/README.md b/README.md index cdc28d7..ba8ce1a 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,22 @@ gkit --profile my-app gsc api call \ Artifacts use no-replace behavior by default. Choose a new output path for a later run, or add `--force` only after reviewing the existing destination. DataForSEO operations that can spend money additionally require both -`--allow-spend` and an explicit `--max-spend-usd` limit. +`--allow-spend` and an explicit `--max-spend-usd` limit. Operations that change +provider state, such as `gsc.sitemaps.submit`, require `--allow-write`; without +it gkit fails with `EFFECT_NOT_ALLOWED` before any request: + +```bash +gkit --profile my-app gsc api call \ + --operation-id gsc.sitemaps.submit \ + --input '{"feedpath":"https://example.com/sitemap.xml"}' \ + --allow-write \ + --out ./gsc-sitemap-submit.json +``` + +Sitemap submission needs Full or Owner permission for the service account in +Search Console; a Restricted user receives `AUTH_FAILED`. Google returns an +empty body, so the artifact is a gkit receipt with `siteUrl`, `feedpath`, and +`httpStatus`. ### HubSpot read-only example diff --git a/packages/gkit/docs/providers/gsc/capabilities.md b/packages/gkit/docs/providers/gsc/capabilities.md index 0b4a532..c72bbec 100644 --- a/packages/gkit/docs/providers/gsc/capabilities.md +++ b/packages/gkit/docs/providers/gsc/capabilities.md @@ -5,7 +5,7 @@ description: > Generated, searchable documentation for the reviewed gsc operations that gkit is allowed to route and execute. provider: gsc -manifestRevision: 2026-07-14.slice4.gsc.2 +manifestRevision: 2026-10-21.gsc.3 --- # gsc reviewed executable capabilities @@ -220,6 +220,49 @@ List sitemaps for one Search Console property. gkit --profile openclaw-web gsc api call --operation-id gsc.sitemaps.list --input '{}' --out gsc-sitemaps.json --dry-run ``` +## gsc.sitemaps.submit + +Submit or resubmit one exact sitemap URL for one Search Console property. Changes provider state, so it requires --allow-write, and the service account needs Full or Owner permission on the property; a Restricted user is rejected with AUTH_FAILED. Google returns an empty body, so the artifact is a gkit submission receipt with siteUrl, feedpath, and httpStatus. + +- Provider: `gsc` +- Adapter key: `sitemaps.submit` +- Capability revision: `1` +- Effects: `write` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "feedpath" + ], + "properties": { + "siteUrl": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "feedpath": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://[^\\s/?#]+" + } + } +} +``` + +### Invocation + +#### Submit the openclaw-web sitemap index + +```bash +gkit --profile openclaw-web gsc api call --operation-id gsc.sitemaps.submit --input @request.json --allow-write --out gsc-sitemap-submit.json --dry-run +``` + ## gsc.url-inspection.inspect Read Google's indexed URL inspection result for one URL under the selected property. diff --git a/packages/gkit/docs/providers/gsc/inventory.md b/packages/gkit/docs/providers/gsc/inventory.md index b60cb41..18fdea3 100644 --- a/packages/gkit/docs/providers/gsc/inventory.md +++ b/packages/gkit/docs/providers/gsc/inventory.md @@ -4,12 +4,12 @@ title: Google Search Console operation inventory description: > Generated inventory of pinned Google Search Console operations and their gkit exposure decisions. provider: gsc -inventoryRevision: 2026-07-14.slice4.gsc.2 +inventoryRevision: 2026-10-21.gsc.3 --- # Google Search Console operation inventory -This pinned inventory contains 10 operations: 5 executable and 5 inventory-only. +This pinned inventory contains 10 operations: 6 executable and 4 inventory-only. Inventory-only operations cannot be routed by `gkit gsc api call`. | Method | Path | Operation ID | Exposure | Decision | @@ -23,4 +23,4 @@ Inventory-only operations cannot be routed by `gkit gsc api call`. | `GET` | `/webmasters/v3/sites/{siteUrl}/sitemaps` | `sitemaps.list` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `gsc.sitemaps.list` | | `DELETE` | `/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}` | `sitemaps.delete` | `inventory` | Destructive operation is outside Slice 4. | | `GET` | `/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}` | `sitemaps.get` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `gsc.sitemaps.get` | -| `PUT` | `/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}` | `sitemaps.submit` | `inventory` | Write operation is outside Slice 4. | +| `PUT` | `/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}` | `sitemaps.submit` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `gsc.sitemaps.submit` | diff --git a/packages/gkit/generated/gsc/inventory.json b/packages/gkit/generated/gsc/inventory.json index 0fbe813..ffdf8ed 100644 --- a/packages/gkit/generated/gsc/inventory.json +++ b/packages/gkit/generated/gsc/inventory.json @@ -1,7 +1,7 @@ { "version": 1, "provider": "gsc", - "revision": "2026-07-14.slice4.gsc.2", + "revision": "2026-10-21.gsc.3", "source": { "url": "https://searchconsole.googleapis.com/$discovery/rest?version=v1", "revision": "2026-07-14-reviewed-contract", @@ -80,8 +80,9 @@ "path": "/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}", "method": "put", "operationId": "sitemaps.submit", - "exposure": "inventory", - "reason": "Write operation is outside Slice 4." + "exposure": "executable", + "capabilityId": "gsc.sitemaps.submit", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." } ] } diff --git a/packages/gkit/generated/gsc/manifest.json b/packages/gkit/generated/gsc/manifest.json index 25cc9a4..1dc2feb 100644 --- a/packages/gkit/generated/gsc/manifest.json +++ b/packages/gkit/generated/gsc/manifest.json @@ -1,13 +1,13 @@ { "version": 1, "provider": "gsc", - "revision": "2026-07-14.slice4.gsc.2", + "revision": "2026-10-21.gsc.3", "source": { "url": "https://searchconsole.googleapis.com/$discovery/rest?version=v1", "revision": "2026-07-14-reviewed-contract", "checksum": "sha256:5b434b16085508148e7fb862070aa24bc6036fae3858f5970376f5e572cc95e0" }, - "reviewedAt": "2026-07-14T06:10:00.000Z", + "reviewedAt": "2026-10-21T00:00:00.000Z", "capabilities": [ { "id": "gsc.properties.list", @@ -212,6 +212,47 @@ } ] }, + { + "id": "gsc.sitemaps.submit", + "provider": "gsc", + "revision": "1", + "adapterKey": "sitemaps.submit", + "title": "Submit one sitemap to Search Console", + "description": "Submit or resubmit one exact sitemap URL for one Search Console property. Changes provider state, so it requires --allow-write, and the service account needs Full or Owner permission on the property; a Restricted user is rejected with AUTH_FAILED. Google returns an empty body, so the artifact is a gkit submission receipt with siteUrl, feedpath, and httpStatus.", + "effects": [ + "write" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "feedpath" + ], + "properties": { + "siteUrl": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "feedpath": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://[^\\s/?#]+" + } + } + }, + "examples": [ + { + "title": "Submit the openclaw-web sitemap index", + "input": { + "feedpath": "https://openclawai.io/sitemap-index.xml" + }, + "command": "gkit --profile openclaw-web gsc api call --operation-id gsc.sitemaps.submit --input @request.json --allow-write --out gsc-sitemap-submit.json --dry-run" + } + ] + }, { "id": "gsc.url-inspection.inspect", "provider": "gsc", diff --git a/packages/gkit/package.json b/packages/gkit/package.json index e9477d2..364fd79 100644 --- a/packages/gkit/package.json +++ b/packages/gkit/package.json @@ -1,6 +1,6 @@ { "name": "gkit", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Profile-bound, agent-first growth provider CLI.", "bin": { diff --git a/packages/gkit/policy/gsc.reviewed.json b/packages/gkit/policy/gsc.reviewed.json index 20a41b2..11681f7 100644 --- a/packages/gkit/policy/gsc.reviewed.json +++ b/packages/gkit/policy/gsc.reviewed.json @@ -1,8 +1,8 @@ { "version": 1, "provider": "gsc", - "manifestRevision": "2026-07-14.slice4.gsc.2", - "reviewedAt": "2026-07-14T06:10:00.000Z", + "manifestRevision": "2026-10-21.gsc.3", + "reviewedAt": "2026-10-21T00:00:00.000Z", "entries": [ { "path": "/webmasters/v3/sites", @@ -173,8 +173,38 @@ "path": "/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}", "method": "put", "operationId": "sitemaps.submit", - "exposure": "inventory", - "reason": "Write operation is outside Slice 4." + "exposure": "executable", + "capability": { + "id": "gsc.sitemaps.submit", + "provider": "gsc", + "revision": "1", + "adapterKey": "sitemaps.submit", + "title": "Submit one sitemap to Search Console", + "description": "Submit or resubmit one exact sitemap URL for one Search Console property. Changes provider state, so it requires --allow-write, and the service account needs Full or Owner permission on the property; a Restricted user is rejected with AUTH_FAILED. Google returns an empty body, so the artifact is a gkit submission receipt with siteUrl, feedpath, and httpStatus.", + "effects": ["write"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["feedpath"], + "properties": { + "siteUrl": { "type": "string", "minLength": 1, "maxLength": 2048 }, + "feedpath": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://[^\\s/?#]+" + } + } + }, + "examples": [ + { + "title": "Submit the openclaw-web sitemap index", + "input": { "feedpath": "https://openclawai.io/sitemap-index.xml" }, + "command": "gkit --profile openclaw-web gsc api call --operation-id gsc.sitemaps.submit --input @request.json --allow-write --out gsc-sitemap-submit.json --dry-run" + } + ] + } }, { "path": "/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}", diff --git a/packages/gkit/scripts/generate-contract-provider.test.ts b/packages/gkit/scripts/generate-contract-provider.test.ts index e00391f..8b1de62 100644 --- a/packages/gkit/scripts/generate-contract-provider.test.ts +++ b/packages/gkit/scripts/generate-contract-provider.test.ts @@ -6,7 +6,7 @@ const packageRoot = new URL("..", import.meta.url).pathname; describe.each([ ["bing", 17, "bing.sites.list"], - ["gsc", 5, "gsc.properties.list"], + ["gsc", 6, "gsc.properties.list"], ] as const)("%s contract generator", (provider, expectedCount, expectedCapability) => { it("is deterministic and binds reviewed capabilities to the pinned source", async () => { const first = await generateContractProviderArtifacts({ packageRoot, provider }); diff --git a/packages/gkit/src/args.test.ts b/packages/gkit/src/args.test.ts index e5651dc..76291bf 100644 --- a/packages/gkit/src/args.test.ts +++ b/packages/gkit/src/args.test.ts @@ -158,6 +158,7 @@ describe("gkit argv parser", () => { profileFlag: "app-a", operationId, input: "{}", + ...(provider === "gsc" ? { allowWrite: false } : {}), out: "result.json", force: false, dryRun: true, @@ -165,6 +166,37 @@ describe("gkit argv parser", () => { }, ); + it("parses --allow-write for GSC calls so the effect gate receives it", () => { + expect( + parseArgs([ + "--profile", + "app-a", + "gsc", + "api", + "call", + "--operation-id", + "gsc.sitemaps.submit", + "--input", + "{}", + "--allow-write", + "--out", + "receipt.json", + ]), + ).toMatchObject({ kind: "gsc-call", operationId: "gsc.sitemaps.submit", allowWrite: true }); + expect(() => + parseArgs(["gsc", "api", "call", "--operation-id", "x", "--input", "{}", "--allow-write=yes"]), + ).toThrow("does not accept a value"); + }); + + it.each(["bing", "hubspot", "dataforseo"] as const)( + "rejects --allow-write for %s, which has no write capabilities", + (provider) => { + expect(() => + parseArgs([provider, "api", "call", "--operation-id", "x", "--input", "{}", "--allow-write"]), + ).toThrow("Unknown flag: --allow-write"); + }, + ); + it("rejects unknown or duplicate flags before execution", () => { expect(() => parseArgs([ diff --git a/packages/gkit/src/args.ts b/packages/gkit/src/args.ts index d0f2025..ed06d67 100644 --- a/packages/gkit/src/args.ts +++ b/packages/gkit/src/args.ts @@ -57,6 +57,7 @@ export type ParsedCommand = profileFlag: string | null; operationId: string; input: string; + allowWrite: boolean; out: string | null; force: boolean; dryRun: boolean; @@ -264,8 +265,28 @@ export function parseArgs(argv: string[]): ParsedCommand { invalid(`Expected ${provider} doctor or ${provider} api call.`); } - const booleanNames = new Set(["--allow-spend", "--force", "--dry-run"]); + const booleanNames = new Set(["--allow-spend", "--allow-write", "--force", "--dry-run"]); const flags = parseFlags(rest.slice(3), booleanNames); + if (provider === "gsc") { + requireOnly(flags, [ + "--operation-id", + "--input", + "--allow-write", + "--out", + "--force", + "--dry-run", + ]); + return { + kind: "gsc-call", + profileFlag, + operationId: requiredString(flags, "--operation-id"), + input: requiredString(flags, "--input"), + allowWrite: flags.get("--allow-write") === true, + out: optionalString(flags, "--out"), + force: flags.get("--force") === true, + dryRun: flags.get("--dry-run") === true, + }; + } if (provider !== "dataforseo") { requireOnly(flags, ["--operation-id", "--input", "--out", "--force", "--dry-run"]); return { @@ -276,9 +297,7 @@ export function parseArgs(argv: string[]): ParsedCommand { ? "hubspot-call" : provider === "google-ads" ? "google-ads-call" - : provider === "gsc" - ? "gsc-call" - : "bing-call", + : "bing-call", profileFlag, operationId: requiredString(flags, "--operation-id"), input: requiredString(flags, "--input"), @@ -344,6 +363,7 @@ export function renderHelp(): string { " gkit --profile gsc doctor", " gkit --profile gsc api call --operation-id --input @request.json --out --dry-run", " gkit --profile gsc api call --operation-id --input @request.json --out ", + " Write capabilities (gsc.sitemaps.submit) also require --allow-write, including --dry-run.", "", "HubSpot:", " gkit --profile hubspot doctor", diff --git a/packages/gkit/src/cli.test.ts b/packages/gkit/src/cli.test.ts index d389bbc..a2f6a8c 100644 --- a/packages/gkit/src/cli.test.ts +++ b/packages/gkit/src/cli.test.ts @@ -197,6 +197,57 @@ describe("gkit process contract", () => { expect(await pathExists(fixture.ledgerPath)).toBe(false); }); + it("routes --allow-write from argv to the GSC effect gate for sitemap submission", async () => { + const fixture = await createCliFixture(); + const submitArgs = [ + "--profile", + "app-a", + "gsc", + "api", + "call", + "--operation-id", + "gsc.sitemaps.submit", + "--input", + JSON.stringify({ feedpath: "https://example.com/sitemap.xml" }), + "--out", + fixture.outPath, + ]; + + const denied = await runMainHarness( + submitArgs, + fixture.env, + "async () => { throw new Error('network must not be used'); }", + ).result; + expect(denied.exitCode).toBe(1); + expect(JSON.parse(denied.stdout)).toMatchObject({ + ok: false, + error: { + code: "EFFECT_NOT_ALLOWED", + outcome: "not_dispatched", + details: { reason: "missing_allow_write" }, + }, + meta: { capability: "gsc.sitemaps.submit", effects: ["write"] }, + }); + expect(await pathExists(fixture.outPath)).toBe(false); + + const planned = await runCli([...submitArgs, "--allow-write", "--dry-run"], fixture.env); + expect(planned.exitCode).toBe(0); + expect(JSON.parse(planned.stdout)).toMatchObject({ + ok: true, + data: { + dryRun: true, + requestPlan: { + provider: "gsc", + capability: "gsc.sitemaps.submit", + method: "PUT", + endpoint: + "https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Aexample.com/sitemaps/https%3A%2F%2Fexample.com%2Fsitemap.xml", + }, + }, + }); + expect(await pathExists(fixture.outPath)).toBe(false); + }); + it("dry-runs and executes PostHog without touching the spend ledger", async () => { const fixture = await createCliFixture(); const args = [ diff --git a/packages/gkit/src/execute-gsc.test.ts b/packages/gkit/src/execute-gsc.test.ts new file mode 100644 index 0000000..25ae81b --- /dev/null +++ b/packages/gkit/src/execute-gsc.test.ts @@ -0,0 +1,243 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ParsedCommand } from "./args"; +import { serializeEnvelope } from "./envelope"; +import { executeGscCall } from "./execute-gsc"; +import { loadExecutableManifest } from "./manifest"; + +const temporaryDirectories: string[] = []; +const feedpath = "https://example.com/sitemap.xml"; +const submitUrl = + "https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Aexample.com/sitemaps/https%3A%2F%2Fexample.com%2Fsitemap.xml"; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("GSC sitemap submission execution boundary", () => { + it.each([false, true])( + "denies gsc.sitemaps.submit without --allow-write (dry-run: %s) before credentials or dispatch", + async (dryRun) => { + const fixture = await createFixture(); + const fetch = vi.fn(); + const deriveAccessToken = vi.fn(); + const result = await executeGscCall({ + command: submitCommand(fixture.outPath, { allowWrite: false, dryRun }), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: fixture.env, + xdgConfigHome: fixture.xdgConfigHome, + fetch, + deriveAccessToken, + }); + + expect(result.exitCode).toBe(1); + expect(result.envelope).toMatchObject({ + ok: false, + error: { + code: "EFFECT_NOT_ALLOWED", + outcome: "not_dispatched", + hint: "Review the request, then rerun with --allow-write.", + details: { reason: "missing_allow_write" }, + }, + meta: { capability: "gsc.sitemaps.submit", effects: ["write"], artifact: null }, + }); + expect(fetch).not.toHaveBeenCalled(); + expect(deriveAccessToken).not.toHaveBeenCalled(); + await expect(readFile(fixture.outPath)).rejects.toThrow(); + }, + ); + + it("plans the PUT on --allow-write --dry-run without credentials or dispatch", async () => { + const fixture = await createFixture(); + const fetch = vi.fn(); + const deriveAccessToken = vi.fn(); + const result = await executeGscCall({ + command: submitCommand(fixture.outPath, { allowWrite: true, dryRun: true }), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: fixture.env, + xdgConfigHome: fixture.xdgConfigHome, + fetch, + deriveAccessToken, + }); + + expect(result.envelope).toMatchObject({ + ok: true, + data: { dryRun: true, requestPlan: { method: "PUT", endpoint: submitUrl } }, + }); + expect(fetch).not.toHaveBeenCalled(); + expect(deriveAccessToken).not.toHaveBeenCalled(); + }); + + it("submits with the write scope and publishes a deterministic receipt for an empty 204", async () => { + const fixture = await createFixture(); + const deriveAccessToken = vi.fn(async () => "write-token-secret"); + const calls: Array<{ url: string; method: string | undefined; auth: string | null }> = []; + const result = await executeGscCall({ + command: submitCommand(fixture.outPath, { allowWrite: true, dryRun: false }), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: fixture.env, + xdgConfigHome: fixture.xdgConfigHome, + deriveAccessToken, + fetch: async (input, init) => { + calls.push({ + url: String(input), + method: init?.method, + auth: new Headers(init?.headers).get("authorization"), + }); + return new Response(null, { status: 204 }); + }, + }); + + expect(deriveAccessToken).toHaveBeenCalledWith(expect.any(Object), "write"); + expect(calls).toEqual([ + { url: submitUrl, method: "PUT", auth: "Bearer write-token-secret" }, + ]); + expect(result.exitCode).toBe(0); + expect(result.envelope).toMatchObject({ + ok: true, + data: { + submitted: true, + siteUrl: "sc-domain:example.com", + feedpath, + httpStatus: 204, + artifactFormat: "gkit-gsc-sitemap-submit-receipt", + artifact: { path: expect.stringMatching(/receipt\.json$/), sha256: expect.any(String) }, + }, + meta: { capability: "gsc.sitemaps.submit", effects: ["write"] }, + }); + expect(JSON.parse(await readFile(fixture.outPath, "utf8"))).toEqual({ + feedpath, + httpStatus: 204, + operation: "sitemaps.submit", + provider: "gsc", + siteUrl: "sc-domain:example.com", + submitted: true, + }); + expect(serializeEnvelope(result.envelope, result.secrets)).not.toContain("write-token-secret"); + }); + + it("surfaces a Restricted-user 403 as AUTH_FAILED with a permission explanation", async () => { + const fixture = await createFixture(); + const result = await executeGscCall({ + command: submitCommand(fixture.outPath, { allowWrite: true, dryRun: false }), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: fixture.env, + xdgConfigHome: fixture.xdgConfigHome, + deriveAccessToken: async () => "write-token-secret", + fetch: async () => + new Response( + JSON.stringify({ + error: { code: 403, message: "User does not have sufficient permission.", status: "PERMISSION_DENIED" }, + }), + { status: 403 }, + ), + }); + + expect(result.exitCode).toBe(1); + expect(result.envelope).toMatchObject({ + ok: false, + error: { + code: "AUTH_FAILED", + outcome: "confirmed", + message: expect.stringMatching(/lacks write permission.*Restricted user/), + details: { + httpStatus: 403, + providerStatus: "PERMISSION_DENIED", + requiredPermissionLevel: "siteFullUser", + }, + }, + }); + expect(await readFile(fixture.outPath, "utf8")).toContain("PERMISSION_DENIED"); + }); + + it("keeps read capabilities on the read-only scope without --allow-write", async () => { + const fixture = await createFixture(); + const deriveAccessToken = vi.fn(async () => "read-token-secret"); + const result = await executeGscCall({ + command: { + ...submitCommand(fixture.outPath, { allowWrite: false, dryRun: false }), + operationId: "gsc.sitemaps.get", + }, + manifest: fixture.manifest, + signal: new AbortController().signal, + env: fixture.env, + xdgConfigHome: fixture.xdgConfigHome, + deriveAccessToken, + fetch: async (_input, init) => { + expect(init?.method).toBe("GET"); + return new Response(JSON.stringify({ path: feedpath }), { status: 200 }); + }, + }); + + expect(deriveAccessToken).toHaveBeenCalledWith(expect.any(Object), "read"); + expect(result.envelope).toMatchObject({ + ok: true, + data: { rowCount: 1, artifactFormat: "exact-gsc-json-response" }, + }); + }); +}); + +function submitCommand( + outPath: string, + flags: { allowWrite: boolean; dryRun: boolean }, +): Extract { + return { + kind: "gsc-call", + profileFlag: "app-a", + operationId: "gsc.sitemaps.submit", + input: JSON.stringify({ feedpath }), + allowWrite: flags.allowWrite, + out: outPath, + force: false, + dryRun: flags.dryRun, + }; +} + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), "gkit-gsc-execute-")); + temporaryDirectories.push(root); + const xdgConfigHome = join(root, "config"); + const profilePath = join(xdgConfigHome, "gkit/profiles/app-a.json"); + await mkdir(dirname(profilePath), { recursive: true }); + await writeFile( + profilePath, + JSON.stringify({ + version: 1, + name: "app-a", + providers: { + gsc: { + config: { siteUrl: "sc-domain:example.com" }, + policy: {}, + secrets: { serviceAccountFile: "env:TEST_GSC_SERVICE_ACCOUNT_FILE" }, + }, + }, + }), + ); + const serviceAccountPath = join(root, "service-account.json"); + await writeFile( + serviceAccountPath, + JSON.stringify({ + type: "service_account", + client_email: "gkit-test@example.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nfake-key-for-tests\n-----END PRIVATE KEY-----\n", + }), + ); + return { + xdgConfigHome, + outPath: join(root, "receipt.json"), + env: { TEST_GSC_SERVICE_ACCOUNT_FILE: serviceAccountPath, HOME: root }, + manifest: await loadExecutableManifest( + new URL("../generated/gsc/manifest.json", import.meta.url).pathname, + ), + }; +} diff --git a/packages/gkit/src/execute-gsc.ts b/packages/gkit/src/execute-gsc.ts index 53c8e3f..e2e2738 100644 --- a/packages/gkit/src/execute-gsc.ts +++ b/packages/gkit/src/execute-gsc.ts @@ -7,9 +7,11 @@ import type { ProviderProfile } from "./profile"; import { deriveGscAccessToken } from "./providers/gsc-auth"; import { dispatchGsc, + gscArtifactFormat, planGscRequest, type GscConfig, type GscCredentials, + type GscFetch, type GscOperation, } from "./providers/gsc"; @@ -20,6 +22,7 @@ export const gscAdapterKeys = new Set([ "search-analytics.query", "sitemaps.list", "sitemaps.get", + "sitemaps.submit", "url-inspection.inspect", ]); @@ -30,7 +33,10 @@ export async function executeGscCall(options: { env?: Readonly>; xdgConfigHome?: string; home?: string; + fetch?: GscFetch; + deriveAccessToken?: typeof deriveGscAccessToken; }) { + const deriveAccessToken = options.deriveAccessToken ?? deriveGscAccessToken; return await executeReadProviderCall({ ...options, spec: { @@ -39,7 +45,7 @@ export async function executeGscCall(options: { readConfig, createOperation: (adapterKey, input) => Object.freeze({ adapterKey, input }), plan: planGscRequest, - prepareCredentials: async ({ profile, resolvedSecrets, readFile, secrets }) => { + prepareCredentials: async ({ profile, effects, resolvedSecrets, readFile, secrets }) => { const serviceAccountFile = resolvedSecrets.serviceAccountFile; if (!serviceAccountFile) throw new TypeError("GSC requires a serviceAccountFile env reference."); @@ -48,7 +54,10 @@ export async function executeGscCall(options: { secrets.register(serviceAccount.privateKey); let accessToken: string; try { - accessToken = await deriveGscAccessToken(serviceAccount.credentials); + accessToken = await deriveAccessToken( + serviceAccount.credentials, + effects.includes("write") ? "write" : "read", + ); } catch { throw new GkitFailure({ code: "AUTH_FAILED", @@ -61,8 +70,9 @@ export async function executeGscCall(options: { secretValues: [serviceAccount.privateKey, accessToken], }; }, - dispatch: dispatchGsc, - artifactFormat: "exact-gsc-json-response", + dispatch: async (dispatchOptions) => + await dispatchGsc({ ...dispatchOptions, fetch: options.fetch }), + artifactFormat: gscArtifactFormat, }, }); } diff --git a/packages/gkit/src/execute-read-provider.ts b/packages/gkit/src/execute-read-provider.ts index d4f0807..c030624 100644 --- a/packages/gkit/src/execute-read-provider.ts +++ b/packages/gkit/src/execute-read-provider.ts @@ -9,14 +9,17 @@ import { type ArtifactReservation, } from "./artifact"; import { type EnvelopeMeta, GkitFailure, SecretRegistry, toFailureEnvelope } from "./envelope"; +import { evaluateEffects } from "./effects"; import type { ExecuteResult } from "./execute"; import { getManifestRecord, ManifestError, type LoadedExecutableManifest, + type ManifestRecord, validateManifestInput, } from "./manifest"; import { + getProviderEnvironment, getProviderProfile, loadProfile, loadProfileEnvironment, @@ -32,13 +35,14 @@ export type ReadProviderCallCommand = { profileFlag: string | null; operationId: string; input: string; + allowWrite?: boolean; out: string | null; force: boolean; dryRun: boolean; }; export type ReadProviderRequestPlan = { - method: "GET" | "POST"; + method: "GET" | "POST" | "PUT"; endpoint: string; diagnosticUrl?: string; }; @@ -56,6 +60,7 @@ export type ReadProviderSpec = { plan(operation: TOperation, config: TConfig): ReadProviderRequestPlan; prepareCredentials(options: { profile: LoadedProfile; + effects: ManifestRecord["effects"]; resolvedSecrets: Readonly>; readFile: typeof readFile; secrets: SecretRegistry; @@ -66,7 +71,7 @@ export type ReadProviderSpec = { credentials: TCredentials; signal: AbortSignal; }): Promise; - artifactFormat: string; + artifactFormat: string | ((operation: TOperation) => string); }; export type ReadProviderExecuteDependencies = { @@ -130,10 +135,14 @@ export async function executeReadProviderCall message: `The ${options.spec.provider} manifest references an unavailable reviewed adapter.`, }); } - if (record.effects.length !== 1 || record.effects[0] !== "read" || record.cost) { + if ( + record.effects.length !== 1 || + (record.effects[0] !== "read" && record.effects[0] !== "write") || + record.cost + ) { throw new GkitFailure({ code: "INTERNAL_ERROR", - message: `${options.spec.provider} Slice 4 capabilities must remain read-only and cost-free.`, + message: `${options.spec.provider} capabilities must be a single read or write effect and cost-free.`, }); } @@ -160,6 +169,22 @@ export async function executeReadProviderCall home: options.home, }); const provider = getProviderProfile(profile, options.spec.provider); + const effectDecision = evaluateEffects({ + record, + input, + profilePolicy: provider.policy, + authorization: { allowWrite: options.command.allowWrite === true }, + environment: getProviderEnvironment(profile, options.spec.provider), + }); + if (!effectDecision.allowed) { + throw new GkitFailure({ + code: "EFFECT_NOT_ALLOWED", + message: effectDecision.message, + hint: effectDecision.hint, + details: { reason: effectDecision.reason }, + meta: contextMeta(context), + }); + } const config = options.spec.readConfig(provider); const operation = options.spec.createOperation( record.adapterKey, @@ -208,6 +233,7 @@ export async function executeReadProviderCall ); const prepared = await options.spec.prepareCredentials({ profile, + effects: record.effects, resolvedSecrets, readFile: dependencies.readFile, secrets, @@ -264,7 +290,10 @@ export async function executeReadProviderCall ok: true, data: { ...providerResult.data, - artifactFormat: options.spec.artifactFormat, + artifactFormat: + typeof options.spec.artifactFormat === "function" + ? options.spec.artifactFormat(operation) + : options.spec.artifactFormat, artifact: context.artifact, }, meta: contextMeta(context), diff --git a/packages/gkit/src/index.md b/packages/gkit/src/index.md index d3b4d34..94cd25e 100644 --- a/packages/gkit/src/index.md +++ b/packages/gkit/src/index.md @@ -33,7 +33,8 @@ gkit --profile my-app gsc api call --operation-id gsc.properties.list --input @r ``` `--profile` wins over `GKIT_PROFILE`. DataForSEO spend calls also need -`--allow-spend` and `--max-spend-usd`. Default artifact behavior is +`--allow-spend` and `--max-spend-usd`. Write capabilities such as +`gsc.sitemaps.submit` need `--allow-write`, including on `--dry-run`. Default artifact behavior is no-replace; add `--force` only after reviewing the destination. ## Anti-Patterns @@ -45,3 +46,4 @@ no-replace; add `--force` only after reviewing the destination. | Skip doctor before a live request | `gkit --profile doctor` | Fail closed on missing config or secrets | | Pipe a provider payload into context | Persist with `--out` and re-read | stdout is an envelope; the bulk is a file | | Merge two App profiles in one process | One profile per invocation | Binding is exclusive | +| Add `--allow-write` to reads or by habit | Only for a reviewed write after its dry-run | Writes change provider state | diff --git a/packages/gkit/src/providers/gsc-auth.ts b/packages/gkit/src/providers/gsc-auth.ts index f07e241..fd266b7 100644 --- a/packages/gkit/src/providers/gsc-auth.ts +++ b/packages/gkit/src/providers/gsc-auth.ts @@ -1,9 +1,19 @@ import { GoogleAuth, type JWTInput } from "google-auth-library"; -const gscScope = "https://www.googleapis.com/auth/webmasters.readonly"; +export type GscAccess = "read" | "write"; -export async function deriveGscAccessToken(credentials: JWTInput): Promise { - const auth = new GoogleAuth({ credentials, scopes: [gscScope] }); +// Reads keep the read-only scope; only reviewed write capabilities request the +// full Search Console scope, so a read never carries write authority. +const gscScopes: Readonly> = { + read: "https://www.googleapis.com/auth/webmasters.readonly", + write: "https://www.googleapis.com/auth/webmasters", +}; + +export async function deriveGscAccessToken( + credentials: JWTInput, + access: GscAccess = "read", +): Promise { + const auth = new GoogleAuth({ credentials, scopes: [gscScopes[access]] }); const client = await auth.getClient(); const tokenResult = await client.getAccessToken(); const accessToken = typeof tokenResult === "string" ? tokenResult : (tokenResult?.token ?? null); diff --git a/packages/gkit/src/providers/gsc.test.ts b/packages/gkit/src/providers/gsc.test.ts index 820f4af..ec4e947 100644 --- a/packages/gkit/src/providers/gsc.test.ts +++ b/packages/gkit/src/providers/gsc.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { dispatchGsc, planGscRequest } from "./gsc"; +import { dispatchGsc, gscArtifactFormat, planGscRequest } from "./gsc"; describe("GSC adapter", () => { it("encodes the property path and keeps Search Analytics as a POST read", async () => { @@ -90,4 +90,142 @@ describe("GSC adapter", () => { endpoint: "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", }); }); + describe("sitemaps.submit", () => { + const feedpath = "https://example.com/sitemap.xml?lang=en&v=2"; + const submit = { adapterKey: "sitemaps.submit", input: { feedpath } } as const; + + it.each([200, 204])( + "PUTs to the encoded sitemap path with no body and writes a receipt for an empty %i", + async (status) => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const result = await dispatchGsc({ + operation: submit, + config: { siteUrl: "sc-domain:example.com" }, + credentials: { accessToken: "access-secret" }, + signal: new AbortController().signal, + fetch: async (input, init) => { + calls.push({ url: String(input), init }); + return new Response(status === 204 ? null : "", { status }); + }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe( + "https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Aexample.com/sitemaps/https%3A%2F%2Fexample.com%2Fsitemap.xml%3Flang%3Den%26v%3D2", + ); + expect(calls[0]!.init?.method).toBe("PUT"); + expect(calls[0]!.init?.body).toBeUndefined(); + const headers = new Headers(calls[0]!.init?.headers); + expect(headers.get("authorization")).toBe("Bearer access-secret"); + expect(headers.has("content-type")).toBe(false); + + expect(result).toEqual({ + ok: true, + providerRequestId: null, + rawBytes: expect.any(Uint8Array), + data: { submitted: true, siteUrl: "sc-domain:example.com", feedpath, httpStatus: status }, + }); + if (!result.ok) throw new Error("expected success"); + expect(Buffer.from(result.rawBytes).toString("utf8")).toBe( + `${JSON.stringify( + { + feedpath, + httpStatus: status, + operation: "sitemaps.submit", + provider: "gsc", + siteUrl: "sc-domain:example.com", + submitted: true, + }, + null, + 2, + )}\n`, + ); + expect(JSON.stringify(result)).not.toContain("access-secret"); + }, + ); + + it("prefers an explicit siteUrl input and plans a PUT", () => { + expect( + planGscRequest( + { adapterKey: "sitemaps.submit", input: { siteUrl: "https://example.com/", feedpath } }, + { siteUrl: "sc-domain:other.com" }, + ), + ).toEqual({ + method: "PUT", + endpoint: + "https://www.googleapis.com/webmasters/v3/sites/https%3A%2F%2Fexample.com%2F/sitemaps/https%3A%2F%2Fexample.com%2Fsitemap.xml%3Flang%3Den%26v%3D2", + }); + expect(gscArtifactFormat(submit)).toBe("gkit-gsc-sitemap-submit-receipt"); + expect(gscArtifactFormat({ adapterKey: "sitemaps.get", input: { feedpath } })).toBe( + "exact-gsc-json-response", + ); + }); + + it("rejects a non-HTTP feedpath before dispatch", () => { + expect(() => + planGscRequest( + { adapterKey: "sitemaps.submit", input: { feedpath: "ftp://example.com/sitemap.xml" } }, + { siteUrl: "sc-domain:example.com" }, + ), + ).toThrow("feedpath must be an absolute HTTP URL"); + }); + + it("explains a 403 for a Restricted service account as a missing write permission", async () => { + const googleBody = JSON.stringify({ + error: { + code: 403, + message: "User does not have sufficient permission for site 'sc-domain:example.com'.", + errors: [{ message: "insufficient permission", domain: "global", reason: "forbidden" }], + status: "PERMISSION_DENIED", + }, + }); + const result = await dispatchGsc({ + operation: submit, + config: { siteUrl: "sc-domain:example.com" }, + credentials: { accessToken: "access-secret" }, + signal: new AbortController().signal, + fetch: async () => new Response(googleBody, { status: 403 }), + }); + + expect(result).toEqual( + expect.objectContaining({ + ok: false, + code: "AUTH_FAILED", + outcome: "confirmed", + retryable: false, + details: { + httpStatus: 403, + providerStatus: "PERMISSION_DENIED", + requiredPermissionLevel: "siteFullUser", + }, + }), + ); + if (result.ok) throw new Error("expected failure"); + expect(result.message).toMatch(/lacks write permission/); + expect(result.message).toMatch(/Restricted user/); + expect(Buffer.from(result.rawBytes!).toString("utf8")).toBe(googleBody); + }); + + it("keeps the existing mapping for other non-2xx submission responses", async () => { + const dispatch = async (status: number) => + await dispatchGsc({ + operation: submit, + config: { siteUrl: "sc-domain:example.com" }, + credentials: { accessToken: "access-secret" }, + signal: new AbortController().signal, + fetch: async () => + new Response(JSON.stringify({ error: { status: "X" } }), { status }), + }); + expect(await dispatch(401)).toMatchObject({ + code: "AUTH_FAILED", + message: "GSC rejected the configured credentials or property access.", + }); + expect(await dispatch(429)).toMatchObject({ code: "RATE_LIMITED", retryable: true }); + expect(await dispatch(503)).toMatchObject({ code: "UNKNOWN_OUTCOME", outcome: "unknown" }); + expect(await dispatch(400)).toMatchObject({ + code: "PROVIDER_ERROR", + message: "GSC rejected the sitemap submission.", + }); + }); + }); }); diff --git a/packages/gkit/src/providers/gsc.ts b/packages/gkit/src/providers/gsc.ts index 4862a6d..a050717 100644 --- a/packages/gkit/src/providers/gsc.ts +++ b/packages/gkit/src/providers/gsc.ts @@ -15,16 +15,26 @@ const webmastersOrigin = "https://www.googleapis.com/webmasters/v3"; const inspectionEndpoint = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect"; type GscRequest = { - method: "GET" | "POST"; + method: "GET" | "POST" | "PUT"; url: string; body?: Record; rowsKey?: string; + // Present only for sitemaps.submit: Google acknowledges it with an empty body. + submission?: { siteUrl: string; feedpath: string }; }; +const submitReceiptFormat = "gkit-gsc-sitemap-submit-receipt"; + +export function gscArtifactFormat(operation: GscOperation): string { + return operation.adapterKey === "sitemaps.submit" + ? submitReceiptFormat + : "exact-gsc-json-response"; +} + export function planGscRequest( operation: GscOperation, config: GscConfig, -): { method: "GET" | "POST"; endpoint: string } { +): { method: GscRequest["method"]; endpoint: string } { const request = buildRequest(operation, config); return { method: request.method, endpoint: request.url }; } @@ -76,6 +86,9 @@ export async function dispatchGsc(options: { } const providerRequestId = safeRequestId(response); + if (request.submission && response.ok) { + return submissionReceipt(request.submission, response.status, providerRequestId); + } let payload: unknown; try { payload = JSON.parse(Buffer.from(rawBytes).toString("utf8")) as unknown; @@ -98,18 +111,28 @@ export async function dispatchGsc(options: { : response.status === 408 || response.status >= 500 ? "UNKNOWN_OUTCOME" : "PROVIDER_ERROR"; + // A Restricted user can read a property but gets 403 on writes; say so + // instead of the generic credentials message. + const writeDenied = Boolean(request.submission) && response.status === 403; return failure({ code, - message: - code === "AUTH_FAILED" + message: writeDenied + ? "GSC denied the sitemap submission: the service account lacks write permission on this property. A Restricted user can read but cannot submit sitemaps; grant it Full or Owner permission in Search Console." + : code === "AUTH_FAILED" ? "GSC rejected the configured credentials or property access." : code === "RATE_LIMITED" ? "GSC rejected the request because its rate limit was reached." - : "GSC rejected the read request.", + : request.submission + ? "GSC rejected the sitemap submission." + : "GSC rejected the read request.", outcome: code === "UNKNOWN_OUTCOME" ? "unknown" : "confirmed", rawBytes, providerRequestId, - details: { httpStatus: response.status, ...safeGoogleError(payload) }, + details: { + httpStatus: response.status, + ...safeGoogleError(payload), + ...(writeDenied ? { requiredPermissionLevel: "siteFullUser" } : {}), + }, }); } if (!isRecord(payload)) { @@ -179,17 +202,45 @@ function buildRequest(operation: GscOperation, config: GscConfig): GscRequest { if (sitemapIndex) url.searchParams.set("sitemapIndex", sitemapIndex); return { method: "GET", url: url.toString(), rowsKey: "sitemap" }; } - if (operation.adapterKey === "sitemaps.get") { + if (operation.adapterKey === "sitemaps.get" || operation.adapterKey === "sitemaps.submit") { const feedpath = requiredString(operation.input, "feedpath"); assertAbsoluteHttpUrl(feedpath, "feedpath"); - return { - method: "GET", - url: `${webmastersOrigin}/sites/${sitePath}/sitemaps/${encodeURIComponent(feedpath)}`, - }; + const url = `${webmastersOrigin}/sites/${sitePath}/sitemaps/${encodeURIComponent(feedpath)}`; + return operation.adapterKey === "sitemaps.get" + ? { method: "GET", url } + : { method: "PUT", url, submission: { siteUrl, feedpath } }; } throw new TypeError("GSC adapter key is not reviewed."); } +// sitemaps.submit succeeds with an empty body, so the artifact is a +// deterministic gkit receipt rather than provider bytes. +function submissionReceipt( + submission: NonNullable, + httpStatus: number, + providerRequestId: string | null, +): RawJsonDispatchResult { + const receipt = { + feedpath: submission.feedpath, + httpStatus, + operation: "sitemaps.submit", + provider: "gsc", + siteUrl: submission.siteUrl, + submitted: true, + }; + return { + ok: true, + rawBytes: new Uint8Array(Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`, "utf8")), + providerRequestId, + data: { + submitted: true, + siteUrl: submission.siteUrl, + feedpath: submission.feedpath, + httpStatus, + }, + }; +} + function stringInput(input: Readonly>, key: string): string | undefined { const value = input[key]; return typeof value === "string" ? value : undefined; diff --git a/packages/gkit/src/schema.ts b/packages/gkit/src/schema.ts index fb40920..8650d0a 100644 --- a/packages/gkit/src/schema.ts +++ b/packages/gkit/src/schema.ts @@ -176,7 +176,7 @@ export function buildGkitSchema( }, ), gsc: group( - { description: `${gscCapabilityCount} reviewed reads.` }, + { description: `${gscCapabilityCount} reviewed capabilities; writes need --allow-write.` }, { doctor: c .meta({ @@ -189,7 +189,7 @@ export function buildGkitSchema( { call: c .meta({ - description: `Call ${gscCapabilityCount} reads: gkit --profile gsc api call --operation-id --input @request.json --out --dry-run.`, + description: `Call ${gscCapabilityCount}: gkit --profile gsc api call --operation-id --input @request.json --out --dry-run.`, examples: [ "gkit --profile gsc api call --operation-id --input @request.json --out --dry-run", ], @@ -298,7 +298,7 @@ function compactRootSchema(generated: string): string { }, ) .replace( - /^\s*\/\*\* (?:\d+ reviewed reads?\.|Profile check\.|Native API\.) \*\/\n/gm, + /^\s*\/\*\* (?:\d+ reviewed (?:reads?\.|capabilities; [^*\n]*)|Profile check\.|Native API\.) \*\/\n/gm, "", ) .replace(/\n{2,}/g, "\n")