Skip to content

1.9.79: a key refused for a scope names it and what grants it - #303

Merged
tracking202 merged 2 commits into
1.9.79from
1.9.79-scope-403-reconnect
Oct 28, 2026
Merged

tracking202 merged 2 commits into
1.9.79from
1.9.79-scope-403-reconnect

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Backlog: E0GQ

Why

A key keeps the scope it was made with. 1.9.79 adds write areas an AI connection may hold (#260 text ads, #265 redirectors), so every hosted connection approved before them is refused on its first such write. The 403 named the scope only in prose, the CLI's hint (the one p202 mcp and the hosted engine hand the assistant) was to mint a key with --scope write, which a hosted connection cannot use, and nothing said to connect again.

What changed

API (api/v3)

  • Auth::requireScope() and the staged-change write check (an area that cannot be told) throw InsufficientScopeException, a subclass of AuthException, so every existing catch (AuthException) behaves as before. hasScope()'s rule moved to a static Auth::scopesSatisfy() it calls, so the same rule can be asked of what a connection would mint.
  • The dispatcher answers it through Support\ScopeRefusal. The body keeps error, message and status. The message keeps its first sentence (Insufficient API key scope for this operation: requires '…' (key has: …).), so a reader matching it still matches, and ends with the remedy, so a reader that knows none of the fields (an older CLI, the PHP CLI, the hosted engine at any version) passes the remedy on. Added fields: code: insufficient_scope, required_scope, key_scope, key_source (ai_connection / api_key / unknown), remedy, and unless key_source is api_key, connect_access.
  • key_source is read from the refused key's own row (label, which only AiConnect writes: AI: <app> via mcp.tracking202.com), scoped to the user it authenticated, and only once a request is refused, so requests that pass pay no query. A failed read logs and answers unknown, which gets both remedies, never "a plain key".
  • connect_access (AiConnect::accessToApprove()) is the least access whose key covers the scope, or the key's own access when that is more, so a "Read and change" connection is never told to choose "Read only"; null when no connection's key can hold the scope (users, system, sync, write), and the remedy then says reconnecting would not add it.
  • AiConnect::ACCESS_LABELS names the three accesses as the approval page does; the page now reads them from there.
  • A refusal for the user's role (Admin access required., a missing permission) is unchanged: error, message, status only.

Nothing is answered that the caller does not already hold: the scope it needed, its own key's scope (already in the message), whether its own key is a connection, and the install's rule for what a connection may hold. No other key or account is named.

Go CLI

  • api.ScopeRefusalOf() reads the fields, or an older install's message (every version words it requires '<scope>' (key has: …)). api.HintFor names the scope and either p202 user apikey create <user_id> --scope <this key's scope plus the missing one> (a key's scope cannot be changed) or connecting the app again with the access the install names; unknown gets both. Exit code stays 2 (auth).
  • In the hosted engine (mcpHostedRun) every scope refusal's hint is to connect again, whatever the install said of the key: its key is the connection's, and that is the one remedy its user has.
  • p202 mcp and the hosted engine hand that envelope (message with the install's remedy, hint with the CLI's) to the assistant unchanged.

Hosted relay. The relay's own code (the Cloudflare Worker at mcp.tracking202.com) is not in this repository. What this repository can do is done: the engine the relay runs (p202 mcp call, js/wasm) puts the remedy in the error envelope it writes, and the install puts it in the message too, for an engine of any version. What it cannot do: make the relay pass that envelope through unchanged (the docs say the relay records "the error when one fails", so it reads it), or have the relay offer a reconnect link from connect_access.

Docs: docs/openapi.yaml (a ScopeRefusal schema for the shared Forbidden response, the scopes intro), documentation/api/00-api-integrations.md (a "A key without the scope" section, the 403 row the status table lacked, the error-response section), docs/cli.md (errors), docs/cli-agent.md (the auth row), documentation/cli/10-go-cli.md (errors and the hosted MCP paragraph), both changelogs. In passing, docs/cli.md's example hint and docs/cli-agent.md's auth row said p202 config get, which is not a command; they say p202 config show, as the CLI does.

Tests

  • tests/Api/V3/ScopeRefusalTest.php (unit): the exception carries scope and key scopes; a role refusal is not one; each source's remedy and fields; never a lesser access; scopes no connection can hold; unknown; keySource() from the label, before the label column, for a removed key and a failed read; accessCovering() against what each access mints; the labels are the page's; and a structural check that AiConnect is the only writer of a key's label (the assumption key_source rests on).
  • tests/Api/V3/ScopeRefusalServedIntegrationTest.php (@group integration, through api/v3/index.php under php -S on a scratch database): a plain campaigns:read key and an old-scope connection (approved "Read and change" before traffic sources could be changed) on PUT /ppc-networks/{id}, the same connection staging it, a connection on POST /text-ads and POST /users, a current connection's write passing, and a role refusal answered as before. The text-ads case reads AiConnect for what to expect, so it holds before and after 1.9.79: text_ad_create, text_ad_update and text_ad_delete in the MCP connector #260 (run both ways here, by adding text-ads to WRITE_AREAS in a scratch copy).
  • StagedChangesControllerTest: the "area cannot be told" refusal is an InsufficientScopeException naming write.
  • Go internal/api/scope_refusal_test.go: reading fields and an older message, other 403s not read as one, the hint per source, the class hint kept when the 403 names no scope.
  • Go cmd/scope_refusal_test.go: p202 ppc-network update against a fake install answering the 1.9.79 bodies and an older one: exitCodeForError 2, hintFor, the envelope; a hosted mcp call (mcpHostedRun) says connect again for all three; the labels held to AiConnect.php. mcp_serve_test.go: the stdio MCP server hands the assistant the envelope. hosted_js_test.go TestHostedScopeRefusalSaysConnectAgain: the relay's whole entry under js/wasm, added to go-cli.yml's list.
  • Agent eval setup-source-002-a-key-without-the-scope-is-told-which-and-how and its reference-agent branch.
  • Planted removals, each caught by a named test (backups to fresh temp files, restores checked with cmp): PHP 1) the dispatcher's branch, 2) requireScope throwing a plain AuthException (unit and served), 3) keySource answering every key as plain (unit and served), 4) a failed read answering api_key, 5) the remedy left out of the message, 6) the access to choose ignoring what the key holds, 7) a connection told to make a key, 8) the staged-change check throwing a plain AuthException. Go 1) HintFor without the refusal's hint, 2) the hosted override, 3) a connection told to make a key, 4) an older install's message not read, 5) the key's scope not carried into the new one, 6) a label that differs from the page's, 7) connect_access: null read as not said, 8) a non-install 403 with requires '…' read as a refusal (this one first went uncaught; a case was added).

Verification

.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed (schema tier on a scratch database), verbatim:

Scope report
------------
  actionlint SKIP  actionlint not installed (brew install actionlint, or the download script CI uses)
  go         PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  golangci   PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  openapi    PASS
  patterns   PASS
  phpcs      PASS
  phpstan    PASS
  schema     PASS
  syntax     PASS
  unit       PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

Unit: 6118 tests, 5 skipped (the 4 "User class requires DB singleton" skips, and CronEntryPointsFailLoudlyTest, which skips in a checkout holding a 202-config.php, as this one did for the live pass). Outside the script: ScopeRefusalServedIntegrationTest (--group integration, 5 tests, against the live instance's database), and the js/wasm tests CI runs (go-cli.yml's two -run lists, TestHostedScopeRefusalSaysConnectAgain included) under Node with go_js_wasm_exec.

Live pass

On a local instance of this branch (php -S, MariaDB 10.11), installed by install-instance.sh's own steps without its licence form (the licence stub is down): the schema, the account and its REST key made by the same calls install.php makes once the key is confirmed.

  • curl: a campaigns:read key and a labelled old-scope connection on PUT /ppc-networks/1 (403 with the fields above, api_key / ai_connection, connect_access: write), the connection on POST /text-ads (connect_access: null on this base, before 1.9.79: text_ad_create, text_ad_update and text_ad_delete in the MCP connector #260) and staging the rename (ppc-networks:stage, still "Read and change").
  • The Go CLI, through a local proxy that changed only its User-Agent so the install's CLI licence gate did not apply (the API is open to other clients with the same key; the gate needs the stub): p202 --profile narrow ppc-network update 1 … exit 2 with the scope hint; following it, p202 user apikey create 1 --scope campaigns:read,ppc-networks:read,ppc-networks:write with the admin key, config set-key, and the same update succeeded. With the connection's key: p202 ppc-network update and p202 mcp call ppc_network_update gave the connect-again hint.
  • The hosted engine itself: GOOS=js GOARCH=wasm build run under Node with P202_HOSTED_URL/P202_HOSTED_API_KEY, mcp call ppc_network_update against the instance: exit 2, envelope with the connect-again hint (and with a plain key, the hosted override).
  • The eval case with the reference agent through p202 eval run (same proxy): mechanical checks pass, needs_judge without a judge; with a judge that answers PASS, 1/1, reply quoting the scope and the hint.

Could not run

  • The Go CLI and the eval case against the instance without the proxy: the licence stub is down, so the install refuses the Go CLI with 402. CI's Agent Evals workflow runs the case with the stub.
  • actionlint (tier SKIP: not installed; running a downloaded copy was refused here). The workflow change is one test name added to a -run list; the file parses as YAML.
  • The upgrade-equals-install check: no schema change.

For the reviewer


Generated by Claude Code

A key keeps the scope it was made with, so every AI connection approved
before a release let connections do more (the text ad and redirector
writes #260 and #265 add) is refused there with a 403 whose body said nothing a program could
read, and the CLI's hint, the one the MCP tools hand the assistant, was to
mint a key with --scope write, which a hosted connection cannot use.

API: Auth::requireScope() and the staged-change write check throw
InsufficientScopeException (an AuthException, so every existing catch is
unchanged). The dispatcher answers it through Support\ScopeRefusal: the
body keeps error, message and status, the message keeps its first sentence
and ends with the remedy (so a reader that knows no field, an older CLI or
the hosted engine, passes it on), and it adds code (insufficient_scope),
required_scope, key_scope, key_source and remedy, and for a connection
connect_access. key_source is read from the refused key's label once the
request is refused (AiConnect is the only writer of a label; a test holds
that), so requests that pass pay nothing; a failed read is "unknown" and
gets both remedies, never "a plain key". connect_access is the least access
whose key covers the scope, or the key's own access when that is more, so
a write connection is never told to choose less; null when no connection's
key can hold it (users, system, sync). A role's 403 answers as before.

Go CLI: api.ScopeRefusalOf reads the fields, or an older install's message,
and HintFor names the scope and either `p202 user apikey create <user_id>
--scope <this key's scope plus the missing one>` or connecting the app
again with the access the install names. Under the hosted engine every
scope refusal says to connect again, since its key is the connection's.
`p202 mcp` and the hosted engine hand that envelope to the assistant.

Tests: ScopeRefusalTest (unit), ScopeRefusalServedIntegrationTest
(through index.php under php -S: a plain key and an old-scope connection
on PUT /ppc-networks, a staged write, text-ads and users writes, and a
role refusal), StagedChangesControllerTest; go: scope_refusal_test.go in
api and cmd (exit code and hint through a command, a hosted mcp call, the
stdio MCP server, labels held to AiConnect.php), and
TestHostedScopeRefusalSaysConnectAgain under js/wasm (added to CI's list).
An agent-eval case (setup-source-002) and its reference-agent branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Copilot AI balanced review requested due to automatic review settings October 28, 2026 09:31
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T09:31:04.752257Z
We couldn’t complete this review. Comment @codex review to try again.
ca5d91b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For ca5d91b: 0 issue(s). The review is encrypted for the maintainers in the review-findings artifact of this run.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For 531af00: 4 finding(s). The report is encrypted for the maintainers in the review-findings artifact of this run.

Rewritten on every push; the check fails on BLOCKING.

Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202
tracking202 merged commit cca0549 into 1.9.79 Oct 28, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants