[object Object]

← back to Cli Printing Press

feat(cli): JSON:API support for OpenAPI parser (envelope, page[cursor], x-prefix) (#505)

5584818b5ce40dab67923a0ed5ffb55944bb6757 · 2026-05-02 12:50:39 -0500 · Cathryn Lavery

* feat(cli): flatten JSON:API resource objects in OpenAPI parser

Detect canonical JSON:API Resource Object shape (type + id + attributes
discriminator) in mapTypes() and hoist `attributes.*` to the top of the
generated TypeDef. Without this, every JSON:API spec produces TypeDefs
where `attributes` is one opaque field and the real columns/flags are
unreachable to downstream consumers (store schema, sync, --select flags).

Gated tight: the trio of type (string) + id (string) + attributes (object)
is the discriminator. Vanilla REST specs (Stripe, HubSpot, Twilio) never
co-locate all three, so the change is byte-compat for them. Verified by
the existing golden suite (9/9 PASS, no fixture updates).

Relationships → foreign-key columns is deferred; v1 omits relationships
entirely from the flattened projection.

Adds testdata/openapi/jsonapi-petstore.yaml fixture and three test cases
(discriminator pinning, JSON:API flattening, non-JSON:API regression).

* feat(cli): support JSON:API cursor pagination and auth prefixes

* refactor(cli): simplify JSON:API cursor and x-prefix auth code

- Drop unreachable strings.Index/url.ParseQuery fallback in
  nextCursorFromLinks: url.Parse already handles unencoded brackets
  (e.g. ?page[cursor]=raw) and the primary path covers it.
- Flatten the 4-level nested x-prefix conditional in mapAuth to 2 levels
  by combining the map lookup and string type assertion (nil interface
  asserts to ("", false), so behavior is identical).
- Drop the Go 1.21-era tc := tc loop capture in the discriminator test;
  unnecessary under per-iteration scoping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 5584818b5ce40dab67923a0ed5ffb55944bb6757
Author: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
Date:   Sat May 2 12:50:39 2026 -0500

    feat(cli): JSON:API support for OpenAPI parser (envelope, page[cursor], x-prefix) (#505)
    
    * feat(cli): flatten JSON:API resource objects in OpenAPI parser
    
    Detect canonical JSON:API Resource Object shape (type + id + attributes
    discriminator) in mapTypes() and hoist `attributes.*` to the top of the
    generated TypeDef. Without this, every JSON:API spec produces TypeDefs
    where `attributes` is one opaque field and the real columns/flags are
    unreachable to downstream consumers (store schema, sync, --select flags).
    
    Gated tight: the trio of type (string) + id (string) + attributes (object)
    is the discriminator. Vanilla REST specs (Stripe, HubSpot, Twilio) never
    co-locate all three, so the change is byte-compat for them. Verified by
    the existing golden suite (9/9 PASS, no fixture updates).
    
    Relationships → foreign-key columns is deferred; v1 omits relationships
    entirely from the flattened projection.
    
    Adds testdata/openapi/jsonapi-petstore.yaml fixture and three test cases
    (discriminator pinning, JSON:API flattening, non-JSON:API regression).
    
    * feat(cli): support JSON:API cursor pagination and auth prefixes
    
    * refactor(cli): simplify JSON:API cursor and x-prefix auth code
    
    - Drop unreachable strings.Index/url.ParseQuery fallback in
      nextCursorFromLinks: url.Parse already handles unencoded brackets
      (e.g. ?page[cursor]=raw) and the primary path covers it.
    - Flatten the 4-level nested x-prefix conditional in mapAuth to 2 levels
      by combining the map lookup and string type assertion (nil interface
      asserts to ("", false), so behavior is identical).
    - Drop the Go 1.21-era tc := tc loop capture in the discriminator test;
      unnecessary under per-iteration scoping.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Trevin Chow <trevin@trevinchow.com>
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 docs/SPEC-EXTENSIONS.md                     |  27 ++++
 internal/generator/generator.go             |   2 +-
 internal/generator/generator_test.go        |  90 +++++++++++++
 internal/generator/templates/config.go.tmpl |   5 +
 internal/generator/templates/sync.go.tmpl   |  54 +++++++-
 internal/openapi/parser.go                  |  81 +++++++++++-
 internal/openapi/parser_jsonapi_test.go     | 190 ++++++++++++++++++++++++++++
 internal/profiler/profiler.go               |   1 +
 testdata/openapi/jsonapi-petstore.yaml      | 138 ++++++++++++++++++++
 9 files changed, 582 insertions(+), 6 deletions(-)

diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 660178c8..5fd029df 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -17,6 +17,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
 | `x-proxy-routes` | `info` | `APISpec.ProxyRoutes` | No |
 | `x-auth-type` | `components.securitySchemes.<name>` | `APISpec.Auth.Type` | No |
 | `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
+| `x-prefix` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
 | `x-auth-cookie-domain` | `components.securitySchemes.<name>` | `APISpec.Auth.CookieDomain` | No |
 | `x-auth-cookies` | `components.securitySchemes.<name>` | `APISpec.Auth.Cookies` | No |
 | `x-resource-id` | path item | `Endpoint.IDField` | No |
@@ -150,6 +151,32 @@ Rules:
 - Only read when `x-auth-type: composed`.
 - Must be a string.
 
+### `x-prefix`
+
+Declares a literal token prefix for header API key schemes.
+
+Parsed field: `APISpec.Auth.Format`
+
+Rules:
+- Optional.
+- Only read for OpenAPI `apiKey` security schemes with `in: header`.
+- Must be a string.
+- Leading and trailing whitespace is trimmed.
+- When present, the parser stores `"<prefix> {token}"` in `Auth.Format`.
+- Ignored for query API keys and non-API-key auth schemes.
+
+Example:
+
+```yaml
+components:
+  securitySchemes:
+    apiKey:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-prefix: Klaviyo-API-Key
+```
+
 ### `x-auth-cookie-domain`
 
 Domain used when extracting named cookies for composed auth.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 13af54da..f9f2dcc2 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2277,7 +2277,7 @@ func isCursorParam(name string) bool {
 	switch lower {
 	case "cursor", "min_cursor", "max_cursor", "next_cursor",
 		"page", "page_token", "next_page_token",
-		"min_time", "max_time", "offset":
+		"page[cursor]", "min_time", "max_time", "offset":
 		return true
 	}
 	return false
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 62557981..3c1cad90 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -567,6 +567,59 @@ func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
 	assert.NotContains(t, content, "bearer-pp-cli auth login\n\nclaude mcp add bearer bearer-pp-mcp")
 }
 
+func TestGenerateAPIKeyAuthFormatSupportsTokenPlaceholder(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "prefix",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			In:      "header",
+			Header:  "Authorization",
+			Format:  "Klaviyo-API-Key {token}",
+			EnvVars: []string{"PREFIX_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/prefix-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/items",
+						Description: "List items",
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	const inlineTest = `package config
+
+import "testing"
+
+func TestAPIKeyFormatTokenPlaceholder(t *testing.T) {
+	cfg := &Config{PrefixApiKey: "secret"}
+	if got := cfg.AuthHeader(); got != "Klaviyo-API-Key secret" {
+		t.Fatalf("AuthHeader() = %q, want %q", got, "Klaviyo-API-Key secret")
+	}
+}
+`
+	testPath := filepath.Join(outputDir, "internal", "config", "auth_format_test.go")
+	require.NoError(t, os.WriteFile(testPath, []byte(inlineTest), 0o644))
+
+	runGoCommandRequired(t, outputDir, "test", "./internal/config")
+}
+
 func countFiles(t *testing.T, root string) int {
 	t.Helper()
 
@@ -1548,6 +1601,8 @@ func TestSyncExtractPaginationNestedCursor(t *testing.T) {
 	// Tier 1: emission canary. The wrapper-key recursion must ship.
 	assert.Contains(t, src, "paginationWrapperKeys",
 		"sync.go must declare paginationWrapperKeys for nested-cursor support")
+	assert.Contains(t, src, "nextCursorFromLinks",
+		"sync.go must parse JSON:API links.next cursors")
 	assert.Contains(t, src, `"response_metadata"`,
 		"paginationWrapperKeys must include response_metadata (Slack's envelope)")
 	assert.Contains(t, src, `"pagination"`,
@@ -1597,6 +1652,41 @@ func TestExtractPageItemsMongoPaginationEnvelope(t *testing.T) {
 	}
 }
 
+func TestExtractPageItemsJSONAPILinksNext(t *testing.T) {
+	body := []byte(` + "`" + `{
+		"data": [{"id":"pet_1"},{"id":"pet_2"}],
+		"links": {
+			"next": "https://api.example.com/pets?page%5Bcursor%5D=opaque-cursor&page%5Bsize%5D=2"
+		}
+	}` + "`" + `)
+	items, cursor, hasMore := extractPageItems(json.RawMessage(body), "page[cursor]")
+	if len(items) != 2 {
+		t.Fatalf("want 2 items, got %d", len(items))
+	}
+	if cursor != "opaque-cursor" {
+		t.Fatalf("want cursor opaque-cursor, got %q", cursor)
+	}
+	if !hasMore {
+		t.Fatalf("want hasMore=true when links.next cursor is present")
+	}
+}
+
+func TestExtractPageItemsJSONAPILinksNextUnencodedBrackets(t *testing.T) {
+	body := []byte(` + "`" + `{
+		"data": [{"id":"pet_1"}],
+		"links": {
+			"next": "https://api.example.com/pets?page[cursor]=raw-brackets"
+		}
+	}` + "`" + `)
+	_, cursor, hasMore := extractPageItems(json.RawMessage(body), "page[cursor]")
+	if cursor != "raw-brackets" {
+		t.Fatalf("want cursor raw-brackets, got %q", cursor)
+	}
+	if !hasMore {
+		t.Fatalf("want hasMore=true when links.next cursor is present")
+	}
+}
+
 // Fast path: top-level cursor with no wrapper present at all. Proves
 // the common case (Stripe, GitHub, Linear, Notion) doesn't enter the
 // wrapper recursion. Pairs with TopLevelCursorWinsOverWrapper which
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 5101caa5..908a1b1a 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -129,10 +129,15 @@ func (c *Config) AuthHeader() string {
 {{- end}}
 	{{- if .Auth.Format}}
 	replacements := map[string]string{
+		"token": token,
 	{{- range .Auth.EnvVars}}
+	{{- if ne (envVarPlaceholder .) "token"}}
 		"{{envVarPlaceholder .}}": c.{{resolveEnvVarField .}},
+	{{- end}}
+	{{- if ne . "token"}}
 		"{{.}}": c.{{resolveEnvVarField .}},
 	{{- end}}
+	{{- end}}
 	}
 	return applyAuthFormat("{{.Auth.Format}}", replacements)
 	{{- else if gt (len .Auth.EnvVars) 0}}
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index e629701e..7cfff962 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -6,6 +6,7 @@ package cli
 import (
 	"encoding/json"
 	"fmt"
+	"net/url"
 	"os"
 	"regexp"
 	"strconv"
@@ -618,12 +619,16 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
 func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
 	var hasMore bool
 
+	nextCursor := nextCursorFromLinks(envelope, cursorParam)
+
 	// Try common cursor field names
 	cursorKeys := []string{
 		"next_cursor", "nextCursor", "cursor", "next_page_token",
 		"nextPageToken", "page_token", "after", "end_cursor", "endCursor",
 	}
-	nextCursor := findCursorInMap(envelope, cursorKeys)
+	if nextCursor == "" {
+		nextCursor = findCursorInMap(envelope, cursorKeys)
+	}
 
 	// If no top-level cursor was found, look one level deeper into well-known
 	// pagination wrapper objects. Slack returns {"messages":[...],
@@ -667,6 +672,53 @@ func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorPa
 	return nextCursor, hasMore
 }
 
+// nextCursorFromLinks extracts JSON:API-style pagination cursors from
+// {"links":{"next":"https://example.com/items?page[cursor]=..."}}.
+func nextCursorFromLinks(envelope map[string]json.RawMessage, cursorParam string) string {
+	rawLinks, ok := envelope["links"]
+	if !ok {
+		return ""
+	}
+	var links map[string]json.RawMessage
+	if json.Unmarshal(rawLinks, &links) != nil {
+		return ""
+	}
+	rawNext, ok := links["next"]
+	if !ok {
+		return ""
+	}
+	var nextURL string
+	if json.Unmarshal(rawNext, &nextURL) != nil || nextURL == "" {
+		return ""
+	}
+
+	cursorKeys := []string{cursorParam}
+	if cursorParam != "page[cursor]" {
+		cursorKeys = append(cursorKeys, "page[cursor]")
+	}
+	if cursorParam != "cursor" {
+		cursorKeys = append(cursorKeys, "cursor")
+	}
+	if cursorParam != "after" {
+		cursorKeys = append(cursorKeys, "after")
+	}
+
+	parsed, err := url.Parse(nextURL)
+	if err != nil {
+		return ""
+	}
+	values := parsed.Query()
+	for _, key := range cursorKeys {
+		if key == "" {
+			continue
+		}
+		if cursor := values.Get(key); cursor != "" {
+			return cursor
+		}
+	}
+	return ""
+}
+
 // findCursorInMap returns the first non-empty string-typed value in m
 // whose key matches one of cursorKeys. Used by extractPaginationFromEnvelope
 // to scan both the top-level envelope and well-known wrapper objects with
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index e22a871e..eeff4aad 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -385,8 +385,15 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
 				break
 			}
 		}
+		if auth.Type == "api_key" && strings.EqualFold(auth.In, "header") {
+			if prefix, ok := scheme.Extensions["x-prefix"].(string); ok {
+				if prefix = strings.TrimSpace(prefix); prefix != "" {
+					auth.Format = prefix + " {token}"
+				}
+			}
+		}
 		// Detect bot token pattern from scheme name (e.g. "BotToken")
-		if strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
+		if auth.Format == "" && strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
 			auth.Format = "Bot {bot_token}"
 		}
 	case "oauth2":
@@ -2115,7 +2122,11 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
 		}
 
 		properties := map[string]*openapi3.SchemaRef{}
-		collectTypeProperties(schemaRef, properties, map[*openapi3.Schema]struct{}{})
+		if isJSONAPIResourceSchema(schema) {
+			jsonAPIFlattenInto(schema, properties)
+		} else {
+			collectTypeProperties(schemaRef, properties, map[*openapi3.Schema]struct{}{})
+		}
 
 		fieldNames := make([]string, 0, len(properties))
 		for fieldName := range properties {
@@ -2289,6 +2300,68 @@ func isObjectSchema(schema *openapi3.Schema) bool {
 	return len(schema.Properties) > 0 || len(schema.AllOf) > 0
 }
 
+// isJSONAPIResourceSchema reports whether the schema is a JSON:API
+// (jsonapi.org/format) Resource Object: a string `type` discriminator,
+// a string `id`, and an `attributes` object. The trio is tight enough
+// that vanilla REST specs (Stripe, HubSpot, Twilio) never accidentally
+// match — they don't co-locate type+id+attributes.
+func isJSONAPIResourceSchema(schema *openapi3.Schema) bool {
+	if !isObjectSchema(schema) {
+		return false
+	}
+	typeRef, hasType := schema.Properties["type"]
+	if !hasType {
+		return false
+	}
+	typeVal := schemaRefValue(typeRef)
+	if typeVal == nil || typeVal.Type == nil || !typeVal.Type.Includes(openapi3.TypeString) {
+		return false
+	}
+	idRef, hasID := schema.Properties["id"]
+	if !hasID {
+		return false
+	}
+	idVal := schemaRefValue(idRef)
+	if idVal == nil || idVal.Type == nil || !idVal.Type.Includes(openapi3.TypeString) {
+		return false
+	}
+	attrsRef, hasAttrs := schema.Properties["attributes"]
+	if !hasAttrs {
+		return false
+	}
+	if !isObjectSchema(schemaRefValue(attrsRef)) {
+		return false
+	}
+	return true
+}
+
+// jsonAPIFlattenInto populates properties with the canonical projection
+// of a JSON:API Resource Object: id (preserved), attributes.* (hoisted
+// to top-level), discriminator `type` dropped (constant per resource).
+// Relationships are intentionally omitted in this pass; foreign-key
+// extraction is a follow-up.
+func jsonAPIFlattenInto(schema *openapi3.Schema, properties map[string]*openapi3.SchemaRef) {
+	if schema == nil {
+		return
+	}
+	if idRef, ok := schema.Properties["id"]; ok && idRef != nil {
+		properties["id"] = idRef
+	}
+	attrsRef, ok := schema.Properties["attributes"]
+	if !ok || attrsRef == nil || attrsRef.Value == nil {
+		return
+	}
+	for name, prop := range attrsRef.Value.Properties {
+		if prop == nil {
+			continue
+		}
+		if strings.HasPrefix(name, "_") {
+			continue
+		}
+		properties[name] = prop
+	}
+}
+
 func isComplexBodyFieldSchema(schema *openapi3.Schema) bool {
 	if isObjectSchema(schema) {
 		return true
@@ -3208,7 +3281,7 @@ func detectPagination(params []spec.Param, op *openapi3.Operation) *spec.Paginat
 	var pag spec.Pagination
 
 	// Detect limit param
-	for _, name := range []string{"limit", "maxresults", "pagesize", "page_size", "max_results", "per_page"} {
+	for _, name := range []string{"limit", "maxresults", "pagesize", "page_size", "max_results", "per_page", "page[size]"} {
 		if _, ok := paramNames[name]; ok {
 			pag.LimitParam = name
 			break
@@ -3225,7 +3298,7 @@ func detectPagination(params []spec.Param, op *openapi3.Operation) *spec.Paginat
 		}
 	}
 	if pag.Type == "" {
-		for _, name := range []string{"after", "cursor"} {
+		for _, name := range []string{"after", "cursor", "page[cursor]"} {
 			if _, ok := paramNames[name]; ok {
 				pag.CursorParam = name
 				pag.Type = "cursor"
diff --git a/internal/openapi/parser_jsonapi_test.go b/internal/openapi/parser_jsonapi_test.go
new file mode 100644
index 00000000..020c9c3d
--- /dev/null
+++ b/internal/openapi/parser_jsonapi_test.go
@@ -0,0 +1,190 @@
+package openapi
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func stringSchema() *openapi3.SchemaRef {
+	return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{openapi3.TypeString}}}
+}
+
+func intSchema() *openapi3.SchemaRef {
+	return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{openapi3.TypeInteger}}}
+}
+
+func objectSchema(props map[string]*openapi3.SchemaRef) *openapi3.Schema {
+	return &openapi3.Schema{
+		Type:       &openapi3.Types{openapi3.TypeObject},
+		Properties: props,
+	}
+}
+
+// TestParseJSONAPI_FlattensAttributes verifies the parser hoists the
+// `attributes` sub-object of a JSON:API Resource Object to the top of the
+// generated TypeDef. Without this, every JSON:API spec produces TypeDefs
+// where `attributes` is one opaque field and the real columns/flags
+// (email, name, etc.) are unreachable to downstream consumers.
+//
+// JSON:API Resource Object discriminator: schema has all of
+// `type` (string), `id` (string), and `attributes` (object). Stripe and
+// HubSpot do not have this combination, so the flattening must be a no-op
+// for them — guarded by detection, not applied universally.
+func TestParseJSONAPI_FlattensAttributes(t *testing.T) {
+	t.Parallel()
+
+	data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "jsonapi-petstore.yaml"))
+	require.NoError(t, err)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	require.NotEmpty(t, parsed.Types)
+	require.Contains(t, parsed.Types, "Pet", "Pet TypeDef must be emitted")
+
+	pet := parsed.Types["Pet"]
+	fieldNames := map[string]string{}
+	for _, f := range pet.Fields {
+		fieldNames[f.Name] = f.Type
+	}
+
+	assert.Contains(t, fieldNames, "id", "primary key id must be preserved")
+	assert.Contains(t, fieldNames, "name", "attributes.name must be hoisted to top-level")
+	assert.Contains(t, fieldNames, "species", "attributes.species must be hoisted to top-level")
+	assert.Contains(t, fieldNames, "age", "attributes.age must be hoisted to top-level")
+
+	assert.NotContains(t, fieldNames, "type", "JSON:API discriminator `type` must be dropped")
+	assert.NotContains(t, fieldNames, "attributes", "envelope wrapper `attributes` must not survive flattening")
+}
+
+func TestParseJSONAPI_DetectsBracketedCursorPagination(t *testing.T) {
+	t.Parallel()
+
+	data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "jsonapi-petstore.yaml"))
+	require.NoError(t, err)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+
+	pets := parsed.Resources["pets"]
+	list := pets.Endpoints["list"]
+	require.NotNil(t, list.Pagination, "list endpoint must detect JSON:API page[cursor] pagination")
+	assert.Equal(t, "cursor", list.Pagination.Type)
+	assert.Equal(t, "page[cursor]", list.Pagination.CursorParam)
+	assert.Equal(t, "page[size]", list.Pagination.LimitParam)
+}
+
+func TestParseJSONAPI_MapsAPIKeyPrefixExtension(t *testing.T) {
+	t.Parallel()
+
+	data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "jsonapi-petstore.yaml"))
+	require.NoError(t, err)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+
+	assert.Equal(t, "api_key", parsed.Auth.Type)
+	assert.Equal(t, "Authorization", parsed.Auth.Header)
+	assert.Equal(t, "header", parsed.Auth.In)
+	assert.Equal(t, "Token-Prefix {token}", parsed.Auth.Format)
+}
+
+// TestParseJSONAPI_LeavesNonJSONAPISpecsAlone confirms the flattening is
+// gated. Existing OpenAPI fixtures (Stripe shape, HubSpot shape) must
+// produce byte-identical TypeDefs to before. petstore.yaml uses a flat
+// shape; if its Pet TypeDef changes after this commit, the gate is wrong.
+func TestParseJSONAPI_LeavesNonJSONAPISpecsAlone(t *testing.T) {
+	t.Parallel()
+
+	data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+	require.NoError(t, err)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	require.Contains(t, parsed.Types, "Pet")
+
+	pet := parsed.Types["Pet"]
+	fieldNames := map[string]bool{}
+	for _, f := range pet.Fields {
+		fieldNames[f.Name] = true
+	}
+
+	// petstore's Pet has top-level name, status, photoUrls — no JSON:API
+	// envelope. These must remain present and unmoved.
+	assert.True(t, fieldNames["name"], "petstore Pet.name must be present (regression check)")
+	assert.True(t, fieldNames["id"], "petstore Pet.id must be present (regression check)")
+}
+
+// TestIsJSONAPIResource_Discriminator pins the gating logic. A schema is a
+// JSON:API Resource Object only when type+id+attributes are all present
+// AND have the right shape. Adjacent shapes (only id+attributes, only
+// type+id) must NOT match — they show up in vanilla REST specs.
+func TestIsJSONAPIResource_Discriminator(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name     string
+		schema   *openapi3.Schema
+		expected bool
+	}{
+		{
+			name: "canonical JSON:API resource object matches",
+			schema: objectSchema(map[string]*openapi3.SchemaRef{
+				"type": stringSchema(),
+				"id":   stringSchema(),
+				"attributes": {Value: objectSchema(map[string]*openapi3.SchemaRef{
+					"foo": stringSchema(),
+				})},
+			}),
+			expected: true,
+		},
+		{
+			name: "missing attributes does not match",
+			schema: objectSchema(map[string]*openapi3.SchemaRef{
+				"type": stringSchema(),
+				"id":   stringSchema(),
+				"foo":  stringSchema(),
+			}),
+			expected: false,
+		},
+		{
+			name: "non-object attributes does not match",
+			schema: objectSchema(map[string]*openapi3.SchemaRef{
+				"type":       stringSchema(),
+				"id":         stringSchema(),
+				"attributes": stringSchema(),
+			}),
+			expected: false,
+		},
+		{
+			name: "non-string id does not match",
+			schema: objectSchema(map[string]*openapi3.SchemaRef{
+				"type":       stringSchema(),
+				"id":         intSchema(),
+				"attributes": {Value: objectSchema(nil)},
+			}),
+			expected: false,
+		},
+		{
+			name: "missing type does not match",
+			schema: objectSchema(map[string]*openapi3.SchemaRef{
+				"id": stringSchema(),
+				"attributes": {Value: objectSchema(map[string]*openapi3.SchemaRef{
+					"foo": stringSchema(),
+				})},
+			}),
+			expected: false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := isJSONAPIResourceSchema(tc.schema)
+			assert.Equal(t, tc.expected, got)
+		})
+	}
+}
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 47953626..b53af569 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -638,6 +638,7 @@ func hasRequiredScopeParams(endpoint spec.Endpoint) bool {
 	paginationParams := map[string]bool{
 		"limit": true, "per_page": true, "page_size": true, "pageSize": true, "first": true, "count": true, "max_results": true,
 		"after": true, "cursor": true, "page_token": true, "offset": true, "page": true, "before": true, "starting_after": true,
+		"page[cursor]": true, "page[size]": true,
 		"since": true, "updated_after": true, "modified_since": true, "since_id": true,
 		"key": true, "format": true, // auth and format params, not scope
 	}
diff --git a/testdata/openapi/jsonapi-petstore.yaml b/testdata/openapi/jsonapi-petstore.yaml
new file mode 100644
index 00000000..a50cefd3
--- /dev/null
+++ b/testdata/openapi/jsonapi-petstore.yaml
@@ -0,0 +1,138 @@
+openapi: 3.0.0
+info:
+  title: JSON:API Petstore
+  version: 1.0.0
+  description: Minimal JSON:API spec used as a generator-side fixture for envelope flattening, page[cursor] pagination, and x-prefix auth tests.
+servers:
+  - url: https://example.com/api
+paths:
+  /pets:
+    get:
+      summary: List pets
+      operationId: listPets
+      parameters:
+        - name: page[cursor]
+          in: query
+          schema: { type: string }
+        - name: page[size]
+          in: query
+          schema: { type: integer }
+      responses:
+        '200':
+          description: OK
+          content:
+            application/json:
+              schema: { $ref: '#/components/schemas/PetList' }
+    post:
+      summary: Create pet
+      operationId: createPet
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema: { $ref: '#/components/schemas/PetCreate' }
+      responses:
+        '201':
+          description: Created
+          content:
+            application/json:
+              schema: { $ref: '#/components/schemas/PetSingle' }
+  /pets/{id}:
+    get:
+      summary: Get pet
+      operationId: getPet
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema: { type: string }
+      responses:
+        '200':
+          description: OK
+          content:
+            application/json:
+              schema: { $ref: '#/components/schemas/PetSingle' }
+    delete:
+      summary: Delete pet
+      operationId: deletePet
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema: { type: string }
+      responses:
+        '204':
+          description: No content
+components:
+  securitySchemes:
+    apiKey:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-prefix: Token-Prefix
+      description: API key auth via Authorization header with Token-Prefix prefix.
+  schemas:
+    Pet:
+      type: object
+      required: [type, id, attributes]
+      properties:
+        type:
+          type: string
+          enum: [pet]
+        id:
+          type: string
+        attributes:
+          type: object
+          required: [name, species]
+          properties:
+            name:
+              type: string
+            species:
+              type: string
+            age:
+              type: integer
+        relationships:
+          type: object
+          properties:
+            owner:
+              type: object
+              properties:
+                data:
+                  type: object
+                  properties:
+                    type: { type: string }
+                    id: { type: string }
+    PetList:
+      type: object
+      properties:
+        data:
+          type: array
+          items: { $ref: '#/components/schemas/Pet' }
+        links:
+          type: object
+          properties:
+            next: { type: string }
+    PetSingle:
+      type: object
+      properties:
+        data: { $ref: '#/components/schemas/Pet' }
+    PetCreate:
+      type: object
+      properties:
+        data:
+          type: object
+          required: [type, attributes]
+          properties:
+            type:
+              type: string
+              enum: [pet]
+            attributes:
+              type: object
+              required: [name, species]
+              properties:
+                name:
+                  type: string
+                species:
+                  type: string
+security:
+  - apiKey: []

← 6e2562d8 docs(cli): bump README "Go 1.22+" prerequisite to "Go 1.26+"  ·  back to Cli Printing Press  ·  chore(main): release 3.5.0 (#501) 401fa7c8 →