Skip to content

Content scope split: opt-in granular content scope mode (T10) - #79

Open
basit3407 wants to merge 5 commits into
mainfrom
feat/content-scope-split-v1
Open

basit3407 wants to merge 5 commits into
mainfrom
feat/content-scope-split-v1

Conversation

@basit3407

@basit3407 basit3407 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Adds an opt-in contentScopeMode: "granular" so the SDK can request the specific scope each content
operation needs, instead of one service-wide content scope.

Context

Part of the Content Scope Split — Cross-Repository Implementation & Migration Plan.

The granular content scopes are not issued to any client yet, so no current integration is
affected. Contract: content-scopes/v1, sha256
ecfced4348e51237ecfe717303d2b180bcaa00a024ae078f2663516f018ba47b

Nothing changes unless you opt in

contentScopeMode defaults to "legacy", which reproduces the previous behaviour exactly —
including the existing path-sniffing special case that mapped public QuranReflect reads to
post.read and comment.read. Existing credentials and existing code are unaffected, and a test
asserts the legacy token requests are unchanged.

Changing the default is a deliberate major-release decision (D6), not something this PR does.

Granular mode

A content call requests the single scope its operation declares. Scope metadata flows from the
contract into the OpenAPI documents (x-qf-scopes) and from there into the generated operation
catalog, so the SDK does not re-derive policy from a path or a service name.

Quota fields are deliberately left out of the catalog: rate limiting is the gateway's business and
must never be an authorization input in a client.

Deliberate refusals rather than fallbacks

  • An untyped client.fetch() call carries no operation, so granular mode cannot know its scope. It
    raises an actionable error naming the escape hatches instead of quietly requesting content,
    which would defeat the mode and fail anyway for credentials never granted it.
  • An insufficient-scope response or an invalid_scope rejection is reported with the requested
    scopes and is never retried with a broader scope. Widening on failure would paper over a
    misconfigured client and hand it more access than it holds.
  • search and analytics.events.write stay separate permissions and are never requested for a
    content call, in either mode.

Token handling fixes found while doing this

  • The cache key was service:scope, which reused one issuer's token against another. It now
    covers the token endpoint, client, audience and canonical scope set, and scope order no longer
    produces duplicate cache entries.
  • Concurrent calls needing the same scopes now share one client-credentials request instead of
    racing several identical ones.
  • Granted scopes are verified when the server reports them. A narrower grant is accepted, since
    RFC 6749 §3.3 allows it; a grant sharing nothing with the request fails immediately with both
    lists named.

Test mock made realistic

The shared MSW token mock returned a fixed scope: "content" for every request. It now echoes the
requested scope, as a real authorization server does for an approved client — otherwise the
granted-scope check is untestable and scope-selection bugs stay hidden.

Source pinning

generate-operation-catalogs gains --source-ref to pin the docs revision, and warns when a build
reads qf-api-docs main. The generated catalog is a published artifact, so reading it from a
moving branch means a rebuild can change client behaviour with no change in this repository.

Tests

29 new in packages/api/test/content-scopes.test.ts, driving a real fetcher with a fake fetch and
asserting the scopes that actually leave the SDK. Full suite 181 passed / 0 failed (was 152).
Lint clean, tsup build succeeds, catalog --check up to date. Changeset included for the release
notes and the minimum-version statement.

Note

tsc --noEmit -p packages/api reports 28 pre-existing strictness errors in existing tests and
mocks; none is in a file this change touches, and packages/api has no typecheck script, so it is
not part of the repo's checks.

Changes since review

Granular mode breaks the typed methods (high priority) and operation-catalog generation not
consistently pinned (CI blocker)
were both addressed earlier in this branch:

  • packages/api/src/lib/content-scope-lookup.ts does a deterministic method/path lookup against
    the pinned contract, and resolveAppScopes falls back to it, so the public typed facades resolve
    granular scopes instead of falling back to broad content. 27 tests drive it through the real
    createServerClient public methods rather than through the helper.
  • scripts/openapi-source.json records the docs revision explicitly, and generation, --check and
    CI all read that one file.

Since the review, the pin moved to the docs revision carrying contract ecfced4348e5, which adds
appCredentialsOnlyScopes. Regenerating against it produces byte-identical catalogs - the new
field is enforcement metadata for the gateway, and the catalogs carry authorization alternatives.
Recorded in the pin's comment so the next person does not have to re-derive why nothing moved.

This SDK cannot reach the restricted case in any event: content scopes are resolved only on the
client_credentials branch of applyAuthHeaders, where Hydra sets sub to the client id, so the
gateway's check does not apply. The user branch sends the stored session's token and never
chooses scopes.

🤖 Generated with Claude Code

Content calls previously requested one service-wide `content` scope, with a
path-sniffing special case for the public QuranReflect reads. This adds
operation-level scope selection driven by the pinned contract, behind an opt-in
flag.

`contentScopeMode` defaults to "legacy" and reproduces the previous behaviour
exactly, including the /quran-reflect/v1/posts/ path handling that mapped to
post.read and comment.read. Existing credentials and existing code are unaffected;
a test asserts the legacy token requests are unchanged. Changing the default is a
deliberate major-release decision (D6), not something this commit does.

In "granular" mode a content call requests the single scope its operation declares.
Scope metadata now flows from the contract into the OpenAPI documents
(x-qf-scopes) and from there into the generated operation catalog, so the SDK does
not re-derive policy from a path or a service name. Quota fields are deliberately
left out of the catalog: rate limiting is the gateway's business and must never be
an authorization input in a client.

Deliberate refusals rather than fallbacks:
- An untyped client.fetch() call carries no operation, so granular mode cannot
  know its scope. It raises an actionable error naming the escape hatches instead
  of quietly requesting `content`, which would defeat the mode and fail anyway for
  credentials never granted it.
- An insufficient-scope response or an invalid_scope rejection is reported with the
  requested scopes and is never retried with a broader scope. Widening on failure
  would paper over a misconfigured client and hand it more access than it holds.
- `search` and `analytics.events.write` stay separate permissions and are never
  requested for a content call, in either mode.

Token handling fixes found while doing this:
- The cache key was `service:scope`, which reused one issuer's token against
  another. It now covers the token endpoint, client, audience and canonical scope
  set, and scope order no longer produces duplicate entries.
- Concurrent calls needing the same scopes now share one client-credentials
  request instead of racing several identical ones.
- Granted scopes are verified when the server reports them. A narrower grant is
  accepted, since RFC 6749 section 3.3 allows it; a grant sharing nothing with the
  request fails immediately with both lists named.

The shared MSW token mock returned a fixed `scope: "content"` for every request.
It now echoes the requested scope, as a real authorization server does for an
approved client -- otherwise the granted-scope check is untestable and scope
selection bugs stay hidden.

generate-operation-catalogs gains --source-ref to pin the docs revision, and warns
when a build reads qf-api-docs main. The generated catalog is a published artifact,
so reading it from a moving branch means a rebuild can change client behaviour with
no change in this repository.

Tests: 29 new (packages/api/test/content-scopes.test.ts) driving a real fetcher
with a fake fetch and asserting the scopes that actually leave the SDK. Full suite
181 passed / 0 failed (was 152). Lint clean, tsup build succeeds, catalog --check
up to date. Changeset added for the release notes and minimum-version statement.

Note: `tsc --noEmit -p packages/api` reports 28 pre-existing strictness errors in
existing tests and mocks; none is in a file this change touches, and packages/api
has no `typecheck` script, so it is not part of the repo's checks.

Refs: Content Scope Split plan work package T10, decision D6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/index.min.js 4.61 KB (0%)
dist/index.min.mjs 4.66 KB (0%)
dist/server.min.js 12.86 KB (+14.68% 🔺)
dist/public.min.js 6.22 KB (0%)

@basit3407

Copy link
Copy Markdown
Collaborator Author

The one red check is a cross-repo ordering dependency, not a defect here

📚 Operation Catalogs fails, and it cannot pass until qf-api-docs#207 merges. Everything else is green: build, lint, size, typecheck, and 181/181 tests.

check:operation-catalogs regenerates the catalog from qf-api-docs main and compares it against the committed one. The committed catalog carries the scopes metadata; docs main has x-qf-scopes on 0 operations until #207 lands, so the two cannot match:

Reading OpenAPI specs from the mutable main branch of qf-api-docs.
Generated operation catalogs are out of date:
- packages/api/src/generated/specs/operation-catalog.json

Verified locally: the same check passes against a docs tree that has the metadata.

This is the T09 → T10 dependency in the plan's own graph — docs must land before the SDK. Merge #207 first, then re-run this check and it goes green with no change here.

I deliberately did not work around it. The two available hacks both make things worse: pinning --source-ref to an unmerged feature-branch SHA would leave a dangling pin on main, and dropping scopes from the committed catalog would leave granular mode with no scope data at all.

🤖 Generated with Claude Code

claude and others added 2 commits September 10, 2026 09:00
Two review findings, both real.

1. Granular mode broke every documented typed method.

resolveAppScopes() required scope metadata on an OperationDefinition. The generated
raw operations carry one, so those worked. The typed convenience facades do not:
client.content.v4.chapters.list() reaches QuranChapters.findAll(), which calls
fetcher.fetch("/content/api/v4/chapters") with a plain URL and no descriptor. So
granular mode threw on the whole public API while the raw API worked. My earlier
tests only exercised requestOperation(), which is exactly why they missed it.

Fixed at the root rather than facade by facade: src/lib/content-scope-lookup.ts
resolves an operation's scopes from service + method + path against the same
pinned catalog. One lookup repairs every facade, present and future.

The lookup is deterministic, not heuristic. A literal path always wins over a
templated one, mirroring how the gateway resolves exact keys before dynamic ones.
A path parameter matches exactly one segment, so /chapters/{id} cannot swallow
/chapters/{id}/info. Where several templates match, the most specific wins. A path
genuinely absent from the contract still returns nothing and still produces the
explicit error -- there is no fall back to broad `content`.

Tests: new test/content-scopes-public-api.test.ts instantiates createServerClient
and calls its real public methods -- chapters, verses, juzs, audio, translations,
tafsirs, languages, answers, hadith references -- asserting the exact scope each
one requests, in both modes. Also asserts all three documented entrypoints for the
same operation (top-level facade, content.v4 facade, raw operation) resolve
identically.

One existing test asserted that an untyped fetch throws in granular mode. That
premise was the bug, so it now asserts the contract lookup resolves it, and a new
test keeps the refusal behaviour using resources/changes, which is genuinely
unassigned.

2. The catalog source was not actually pinned.

Adding an optional --source-ref did not change what CI runs. CI invokes
check:operation-catalogs with no arguments, which read the mutable qf-api-docs main
branch -- so the check was both non-reproducible and failing.

scripts/openapi-source.json now records the approved revision, and generation,
--check and CI all resolve their default from that one committed file. The
generator warns when the pinned ref is not a full commit SHA, because a branch is
not a reproducible input.

The pin is read lazily and tolerantly: test/operation-catalog-generator.test.js
copies the generator to a temp directory and imports it standalone, where the pin
file is absent. A missing pin falls back to the branch and says so rather than
failing at import. CI runs from a full checkout, so CI is always pinned.

It currently points at the head of qf-api-docs feat/content-scope-split-v1, the
revision carrying x-qf-scopes, with a TODO to move it to the resulting commit on
main once quran/qf-api-docs#207 lands. The catalog was regenerated from that exact
revision, so `check` passes with no arguments.

Tests: 209 passed / 0 failed (was 181). Lint clean, tsup build succeeds,
check:operation-catalogs passes with CI's exact invocation.

Refs: review findings 2 and 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nlyScopes

The pinned qf-api-docs revision now carries contract sha256 ecfced4348e5, which
records that `content.reflections.read` must not authorize a user-bound token.

Regenerating against it produces byte-identical catalogs, which is the expected
result: the new field is enforcement metadata for the gateway, and the catalogs
carry authorization alternatives. Recorded in the pin's comment so the next
person does not have to re-derive why nothing moved.

This SDK cannot reach the restricted case in any event. Content scopes are
resolved only on the `client_credentials` branch of `applyAuthHeaders`, where
Hydra sets `sub` to the client id, so the gateway's check does not apply. The
`user` branch sends the stored session's token and never chooses scopes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@basit3407

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

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 Completed 2026-09-13T10:53:47.176350Z adadbab Manual request
ℹ️ 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a72703074

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/generate-operation-catalogs.mjs Outdated
Comment thread packages/api/README.md Outdated
@basit3407

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: adadbab402

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/README.md Outdated
Comment thread packages/api/src/sdk/fetcher.ts
Comment thread packages/api/src/sdk/fetcher.ts Outdated
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.

2 participants