Skip to content

feat: tickets endpoint + N-scopes-per-key auth refactor (v2.2.0)#53

Merged
SyniRon merged 32 commits into
developfrom
Feat/auth-scopes-and-tickets
May 19, 2026
Merged

feat: tickets endpoint + N-scopes-per-key auth refactor (v2.2.0)#53
SyniRon merged 32 commits into
developfrom
Feat/auth-scopes-and-tickets

Conversation

@SyniRon

@SyniRon SyniRon commented May 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ships v2.2.0 of the 7Cav API: scope-based auth aligned with Cav7/ApiKeyManager 1.1.0 + a new read-only tickets endpoint backed by the xf_nf_tickets_* XenForo addon.

  • Adds the read-only tickets endpoint (GET /api/v1/tickets/*) backed by the nixfifty XenForo addon. Five RPCs: ListTickets, GetTicket, GetTicketByRef, ListTicketMessages, ListCategories.
  • Bundles the API-side half of the Cav7/ApiKeyManager 1.1.0 auth refactor: xf_cav7_api_key.scope_read boolean is replaced with a scope catalog (xf_cav7_api_key_scope_def + xf_cav7_api_key_scope junction). Each handler explicitly declares its required scope via RequireScope(ctx, "<name>")"read" for milpacs, "read:tickets" for the new service.
  • Adds an in-memory reference cache for status / priority / prefix names + the category tree, refreshed every 15min (configurable via REFERENCE_CACHE_REFRESH_INTERVAL).
  • Tickets routes bypass Redis (no monitoredTables additions — intentional; the spec covers why).
  • Net-new repo testing surface: this PR lands the first _test.go files in the repo — 28 tests covering the new code paths (auth refactor + reference cache + tickets datastore + tickets handlers + smoke-fix coverage).
  • Bumps version to v2.2.0.
  • New env vars: FORUM_BASE_URL (optional; populates Ticket.forum_url) and REFERENCE_CACHE_REFRESH_INTERVAL (optional, Go duration, default 15m).

Smoke-fix batch (added 2026-05-17)

After the initial PR opened, the branch was deployed to api-staging on traycer and a full smoke matrix was run. Four code/spec gaps surfaced; this PR now also contains the fixes for all of them, plus two reviewer-driven fixups.

ID Issue Fix
G7 Authorization: bearer <token> (lowercase scheme) returned 401 New shared datastores.ParseBearerToken helper, case-insensitive per RFC 7235. Both gRPC interceptor and HTTP middleware use it.
C3 Garbage after_cursor returned HTTP 500 New datastores.ErrInvalidCursor sentinel; handlers map via errors.Iscodes.InvalidArgument → HTTP 400 with message "invalid after_cursor".
A4 Parent spec promised comma-separated multi-select; reality was repeat-the-param only Doc-only — six repeated filters on ListTicketsRequest carry doc-comments surfaced as OpenAPI parameter descriptions. Custom unmarshaller deferred.
E1 No total_message_count on Ticket; messages at position=0 were unreachable New Ticket.total_message_count = reply_count + 1 (no extra query). ListTicketMessages switched to opaque base64 string cursor. SQL boundary tightened from position > ? to position >= ? (caught during re-smoke) with cursor semantic shifted to "next position to include".

Reviewer-driven fixups

  • decodeCursor and decodeMessageCursor use strconv.ParseUint instead of fmt.Sscanf (rejects trailing garbage like base64("42abc")).
  • GetTicketResponse.total_message_count (field 3) marked [deprecated = true] with removal target v2.3.0; still populated through v2.2.0 as a one-release migration runway. Consumers should switch to Ticket.total_message_count (newly available on every Ticket-returning endpoint).

API contract caveats

  • ListTicketMessages cursor type change (uint32 after_positionstring after_cursor, both request and response). Wire-incompat in isolation but v2.2.0 is unreleased and the known consumers (7cav-cavbot2, 7cav-adr) don't call this RPC. Field-number reuse on request field 3 and response field 2 is intentional and safe — the previous types never shipped.
  • Authorization: bearer <token> now succeeds where previously it returned 401. Tightening side-effect: a bare Authorization: <token> with NO scheme prefix is now rejected (the old code silently no-op'd TrimPrefix and accepted bare tokens). No known consumer sent bare tokens.
  • after_cursor malformed input → HTTP 400 instead of 500. Behavioral change in the wrong-input direction; consumers that special-cased 500 will now miss the InvalidArgument signal (none known to do this).

Coordination — must read before deploying

This PR is deploy-gated against Cav7/ApiKeyManager 1.1.0. The addon's migration drops the scope_read column and creates the two new scope tables. The auth query in this PR references the post-migration schema; the old query referenced the column that's about to be dropped. There is no overlap window where both queries work.

Recommended sequence:

  1. Merge this PR to develop; let the next release cut produce the v2.2.0 image.
  2. Upgrade Cav7/ApiKeyManager to 1.1.0 on the XF board (xf-addon:rebuild Cav7/ApiKeyManager). Migration: scope_def + junction tables created, every active key backfilled with read, scope_read column dropped, read:tickets scope row seeded with permission gating.
  3. Deploy the API. The tickets endpoint goes live in the same deploy.
  4. Grant read:tickets to the MCP server's API key via the addon ACP.

Brief downtime alternative: skip the gate, do steps 2+3 back-to-back.

Test plan

  • go test -vet=off ./... green locally (pre-existing Fatalf format-string warnings in servers/server.go:139/183/191 are out of scope; will file a separate maintenance ticket)
  • make lint green (buf lint + buf breaking --against develop, exit 0)
  • Local validation against the dev DB at ~/repos/xenforo-db: ListTickets with category subtree expansion returns S6 tickets, GetTicketByRef populates forum_url, scope rejection returns 403 for read-only keys on tickets routes, milpacs routes still work for both full-access and read-only keys, cache bypass confirmed via server log diff
  • Staging smoke matrix against api-staging on traycer (2026-05-17, branch HEAD d9212d4):
    • G7 — lowercase bearer, ALL-CAPS BEARER, canonical Bearer all → 200; no-scheme prefix → 401
    • C3 — ?after_cursor=garbage-not-base64 → 400 {"code":3,"message":"invalid after_cursor"}; trailing-garbage cursor → 400
    • A4 — Swagger JSON has "Comma-separated form is NOT supported" descriptions on all 6 repeated filters
    • E1 — GET /tickets/6899 returns total_message_count: 81, reply_count: 80; /messages first page positions [0, 1, 2]; full pagination yields exactly 81 messages (previously stopped at 80 due to unreachable position=0)
  • Reviewer: confirm OpenAPI page renders the repeated-filter doc-comments in the Swagger UI
  • Post-merge: addon upgrade lands before image deploy

Notes for reviewers

  • The go.sum has a 422-line reduction from go mod tidy cleaning up stale transitive entries left by previous PRs. Not part of this PR's work; happened during dep refresh in the first commit (77117f8).
  • The v2.2.0 generate output includes catch-up bytes (a few characters) for proto/milpacs.pb.go and third_party/OpenAPI/milpacs.swagger.json which had pre-existing drift (stuck at 1.7.x while the proto declared 2.1.1). The catch-up isn't new work — just the regeneration finally syncing to the proto source.
  • The TicketsService error mapping (errors.Is(err, gorm.ErrRecordNotFound)codes.NotFound, others → codes.Internal) is stricter than MilpacsService, which currently maps any non-nil error to codes.NotFound. Filed as a follow-up to align milpacs to the new pattern; not addressed in this PR to keep the diff focused.
  • Pre-existing Fatalf format-string vet warnings at servers/server.go:139,183,191 are flagged for a separate maintenance follow-up; they are NOT a regression from this PR.

🤖 Generated with Claude Code

SyniRon and others added 30 commits May 16, 2026 15:28
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ApiKeyResult.ScopeRead -> Scopes map[string]struct{} + HasScope()
- ValidateApiKey rewritten to JOIN xf_cav7_api_key_scope_def and _scope
- gRPC interceptor attaches *ApiKeyResult to handler ctx, drops ScopeRead gate
- HTTP middleware drops ScopeRead gate (validation still fail-fast on bad tokens)
- All milpacs handlers explicitly RequireScope(ctx, "read")
- Adds RequireScope + KeyFromContext + ContextWithKey helpers under servers/grpc/

Coordinated with Cav7/ApiKeyManager 1.1.0; see addon migration notes.
Holds status/priority/prefix phrase names + the category tree. Refresh
is loader-driven (the loader implementation lives in the datastore package
next). Cache miss returns empty/nil — never an error — because the cache
can be cold relative to addon-side additions.
Reads ticket status/priority/prefix phrase names from xf_phrase and the
full category tree from xf_nf_tickets_category. Caller (referencecache.Cache)
calls these at startup and every refresh tick.
Fails fast if the initial load errors (DB unreachable, schema mismatch).
After startup the refresh goroutine ticks every 15min (configurable via
REFERENCE_CACHE_REFRESH_INTERVAL env var); refresh failures log but never
blank the cache.
- Replace fmt.Sscanf with strconv.ParseUint for stricter id parse
- Add cycle protection to CategoryAncestors traversal
- Thread context.Context through refresh goroutine for future cancellation
Mirrors milpacs.proto convention so Swagger UI renders the Authorize
button for tickets and each RPC shows a summary + description + tag
group instead of just the operationId.
- gorm.ErrRecordNotFound -> codes.NotFound (matches MilpacsService convention)
- Other datastore errors -> codes.Internal with operation context
- Strengthen TestListTickets_HappyPath to assert all 11 filter fields
- Add TestGetTicket_NotFoundReturns404Code
- servers/server.go const version + proto/milpacs.proto openapiv2_swagger.info.version
  bumped lockstep per CLAUDE.md
- proto/tickets.proto already declares 2.2.0 (set in Phase 3)
- make generate output includes catch-up bytes for milpacs.pb.go +
  third_party/OpenAPI/milpacs.swagger.json which had pre-existing drift
  (stuck at 1.7.x) — the catch-up isn't new work, just the regeneration
  finally syncing them to the proto source
Adds a case-insensitive Bearer-scheme parser in datastores so the gRPC
interceptor and HTTP middleware can share one implementation.

RFC 7235 says auth-scheme tokens are case-insensitive; lowercase 'bearer'
previously returned 401 (caught as G7 in the 2026-05-16 staging smoke
test).

Call-site swap follows in the next commit.
Replaces the inline TrimPrefix with the case-insensitive helper from the
previous commit. No functional change beyond G7's case-sensitivity fix.
Closes G7 from the 2026-05-16 staging smoke test — lowercase 'bearer'
Authorization headers now authenticate correctly. Both auth surfaces
share one parser, eliminating the lockstep-or-drift footgun called out
in CLAUDE.md.
decodeCursor now wraps a sentinel error so the handler layer can map
malformed cursors to codes.InvalidArgument. Handler mapping follows in
the next commit.
Closes C3 from the 2026-05-16 staging smoke test — malformed
after_cursor on /api/v1/tickets now returns HTTP 400 instead of 500.

Detected via errors.Is on the ErrInvalidCursor sentinel introduced in
the previous commit.
Adds doc-comments to all six repeated filters on ListTicketsRequest so
the OpenAPI page surfaces the supported multi-select form.

Closes A4 from the 2026-05-16 staging smoke test — comma-separated form
(?status_id=1,3) was never wired and the parent spec wrongly promised
it would work. Aligning the contract with reality is the YAGNI fix; a
custom unmarshaller is deferred until a hand-typed-URL consumer asks
for it.
Closes E1 from the 2026-05-16 staging smoke test.

- Adds total_message_count to Ticket (derived from reply_count + 1,
  no extra query). Both ListTickets and GetTicket responses carry it.
- Deprecates GetTicketResponse.total_message_count (use
  ticket.total_message_count). Removal target: v2.3.0.
- Switches ListTicketMessages cursor from uint32 after_position to
  opaque string after_cursor + base64-encoded position. Closes the
  proto3-zero-value gap that made position=0 messages unreachable
  (smoke run: ticket 6899 paginated 80/81 messages).
- Maps ErrInvalidCursor → codes.InvalidArgument on ListTicketMessages
  to match ListTickets.

Field-number reuse on ListTicketMessagesRequest.after_cursor and
ListTicketMessagesResponse.next_cursor is intentional and safe: the
previous uint32 types never shipped — v2.2.0 is unreleased.
SyniRon added 2 commits May 17, 2026 00:04
fmt.Sscanf accepts a leading numeric prefix and silently discards the
rest, so base64('42abc') decoded to (pos=42, nil) instead of erroring.
Replaces both decodeCursor and decodeMessageCursor with strconv.ParseUint
which is strict.

Adds trailing-garbage test cases to both garbage suites and a
position=0 round-trip test that pins the regression target from
smoke ticket 6899 (the original reason for the opaque-cursor switch
in the previous commit).
Smoke-test on staging caught that the prior opaque-cursor fix only
addressed the wire-format zero-value collapse; the underlying SQL
boundary `position > afterPos` still excluded position=0 on the empty-
cursor first page. Confirmed on ticket 6899 — paginated 80/81 again.

- SQL boundary changed to `position >= ?` so afterPos=0 (empty cursor)
  includes the starter post.
- Cursor semantic shifts: encodes the NEXT position to include
  (last_returned + 1) rather than the last position returned. Round-
  trip property unchanged; consumers treat the cursor as opaque.
- Adds TestListTicketMessages_EmptyCursorReachesPositionZero pinning
  the regression target.
@SyniRon
SyniRon merged commit 30f0082 into develop May 19, 2026
2 checks passed
@SyniRon
SyniRon deleted the Feat/auth-scopes-and-tickets branch May 19, 2026 00:48
SyniRon added a commit that referenced this pull request May 20, 2026
#81)

Eliminates the manual two-place version bump that's been a recurring
near-miss at release time (the 2.2.0 deploy nearly shipped with the
const unbumped; only caught because PR #53 happened to include it).

Changes:

- servers/server.go: `const version = "2.2.0"` → `var version = "dev"`
  so -ldflags can override at link time.

- proto/milpacs.proto + proto/tickets.proto: openapiv2_swagger
  info.version literal switched to "dev" sentinel.

- Dockerfile: accepts `ARG VERSION=dev`, passes it through
  `-ldflags="-X github.com/7cav/api/servers.version=${VERSION}"`.

- .github/workflows/build_and_push.yml: new step that sed-substitutes
  `${{ github.ref_name }}` into both .proto files before docker build,
  and passes the same value through as a build-arg. Both the runtime
  server log and the OpenAPI spec at `/` will report the released tag
  on production builds; local dev builds continue to report "dev".

Mirrors the ldflags-injection pattern cavbot2 adopted in 0.7.4.

Verified locally:
- `go build` → "Starting 7Cav API version: dev"
- `go build -ldflags=...` → "Starting 7Cav API version: 2.2.1-test"
- `make generate` clean with the "dev" sentinel; openapi JSONs render
  `"version": "dev"` as expected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant