Skip to content

feat: add spacecat-shared-ticket-client package with Jira Cloud integration (SITES-44690)#1701

Merged
prithipalpatwal merged 30 commits into
mainfrom
feat/SITES-44690-ticket-client
Jul 3, 2026
Merged

feat: add spacecat-shared-ticket-client package with Jira Cloud integration (SITES-44690)#1701
prithipalpatwal merged 30 commits into
mainfrom
feat/SITES-44690-ticket-client

Conversation

@prithipalpatwal

@prithipalpatwal prithipalpatwal commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Jira: SITES-44690

Summary

Adds the spacecat-shared-ticket-client package — a provider-agnostic ticket management client with Jira Cloud as
the first implementation.

Package Structure

src/
├── adf/
│   └── markdown-to-adf.js              # Markdown → ADF conversion (marked.lexer)
├── clients/                                                                                                      
│   ├── base-ticket-client.js           # Abstract base (createTicket contract)
│   └── jira-cloud-client.js            # Jira REST API v3 provider                                               
├── credentials/                                                                                                
│   ├── credential-manager-factory.js   # Auth strategy dispatch
│   └── oauth-credential-manager.js     # OAuth 2.0 3LO token lifecycle                                           
├── http/
│   └── rate-limit-aware-http-client.js # 429 retry with backoff + quota fail-fast                                
├── ticket-client-factory.js            # Composition root (wires everything)                                     
├── index.js                            # Barrel exports
└── index.d.ts                          # TypeScript declarations                                                 

Key Capabilities

JiraCloudClient

  • Targets Jira REST API v3
  • Markdown-to-ADF description conversion (full Markdown — headings, lists, code blocks, bold/italic, links)
  • 255-character summary truncation (server-enforced; validated client-side)
  • Non-empty summary validation before API call
  • Label whitespace validation (Jira silently rejects labels with spaces)
  • dueDate calendar validity check via UTC-safe component round-trip (e.g. rejects 2026-02-31)
  • Code-point-safe truncation in sanitizeSummary and sanitizeFilename — avoids splitting surrogate pairs in
    emoji strings
  • uploadAttachment() with MIME type whitelist, magic-byte validation, filename sanitization
  • listProjects() and listIssueTypes() with pagination support

RateLimitAwareHttpClient

  • Automatic HTTP 429 retry with exponential backoff
  • Retry-After header support
  • Quota-exhaustion fail-fast (no retry on hour-long quota windows)
  • Non-idempotent safety: POST/PUT/PATCH skip 5xx and network-error retry — server may have processed before
    failing; retrying would risk duplicate ticket creation

OAuthCredentialManager

  • OAuth 2.0 3LO token lifecycle management
  • In-process #refreshLock / #forceRefreshLock Promise gates to serialise concurrent same-Lambda refresh calls
  • getAuthHeaders(bypassCache=true) to bypass the 5-min TTL cache on Jira API 401 retry
  • Concurrent-safe refresh via Secret Manager pre-read
  • GRANT_REVOKED recovery on Atlassian 403 — re-reads SM twice (rotation-race check), then writes requiresReauth: true if no fresh tokens found (see Grant Failure section below)
  • Secret Manager write retry with exponential backoff + idempotency tokens
  • SM secret accessToken structure validation — surfaces corrupt secrets early instead of sending Bearer undefined to Jira
  • Supports minimal { requiresReauth: true } SM payloads (e.g. written by admin tooling to force re-auth)
  • Refresh token reuse: Atlassian permits the same refresh token to be exchanged multiple times within a 10-minute
    window — concurrent Lambda refreshes both succeed (last writer to SM wins, no token loss); guards are
    defense-in-depth for efficiency, not correctness-critical

Security

  • SSRF protection: all requests routed through fixed Atlassian gateway (api.atlassian.com); cloudId validated
    as UUID; siteUrl validated as https://*.atlassian.net and used for display only, never as a request target
  • Path traversal protection: organizationId and connectionId validated as UUIDs before Secret Manager path
    interpolation; attachment filenames sanitized
  • DoS protection in markdownToAdf: 1 MB input cap (UTF-8 byte-accurate via Buffer.byteLength), depth limit 20,
    fallback to plain paragraph

Design

  • SOLID-compliant
    • SRP: ADF conversion in its own module; createTicket() delegates to #validateTicketInput() +
      #buildTicketBody() private methods
    • OCP: Map-based provider dispatch
    • ISP: Provider-specific methods implemented only on subclasses
    • DIP: All dependencies injected via constructors
  • Composition Root: TicketClientFactory.create() wires credential manager → rate-limited HTTP client →
    provider client; consumers use a single client.createTicket() entry point
  • Single-level inheritance: JiraCloudClient extends BaseTicketClient; no deep hierarchy

Grant Failure — Atlassian 403 + GRANT_REVOKED

Atlassian returns HTTP 403 for all refresh token failures. Two error codes are known:

Code Source Scenarios
invalid_grant [Official Atlassian
docs](https://developer.atlassian.com/cloud/oauth/getting-started/refresh-tokens/) Token expired (90-day
inactivity), token not rotated, password changed
unauthorized_client Empirically verified Token reused past 10-min window, app revoked, malformed/truncated
token, random/empty string

Both trigger code: 'GRANT_REVOKED' — callers check a semantic signal, not a raw HTTP status.
#recoverFromRevokedGrant re-reads SM twice (immediate + 200ms wait for rotation-race recovery). If no fresh
tokens found, writes requiresReauth: true to SM and throws.

Alignment

Implements the v1 scope from mysticat-architecture PR
#150
. Coverage includes all API endpoints, field
mappings, error handling, and retry flows.

Secret path pattern: /mysticat/task-management/{orgId}/{connectionId}

@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Request changes - solid scaffolding with a few security and correctness gaps to close before merge.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (23 files).

Must fix before merge

  1. [Critical] Secret path injection - buildSecretPath interpolates organizationId/connectionId without validation; a malformed DB record with ../ can traverse the Secrets Manager namespace - src/ticket-client-factory.js:44 (details inline)
  2. [Important] Unused production dependencies - @adobe/fetch and @adobe/spacecat-shared-utils are declared in dependencies but never imported anywhere in src/; ships dead weight to consumers - package.json:41 (details inline)
  3. [Important] Uncapped Retry-After - #resolveWaitMs trusts the server-sent Retry-After header without an upper bound; a value of 86400 blocks the Lambda for a full day - src/http/rate-limit-aware-http-client.js:62 (details inline)
  4. [Important] ClientRequestToken misuse - access_token.slice(0, 32) may collide across refreshes or contain invalid characters for SM's idempotency token; use a proper UUID or deterministic hash instead - src/credentials/oauth-credential-manager.js:90 (details inline)
Non-blocking (5): minor issues and suggestions
  • nit: content?.length works for Buffer/Uint8Array but will silently under-count multi-byte strings if the type contract ever loosens - consider Buffer.byteLength or a type guard - src/clients/jira-cloud-client.js:203
  • nit: c8, mocha, mocha-multi-reporters are used in scripts/config but not in devDependencies - works via workspace hoisting but explicit is better - package.json:44
  • suggestion: add jitter to exponential backoff (waitMs * (0.5 + Math.random() * 0.5)) to avoid thundering herd on shared rate limits - src/http/rate-limit-aware-http-client.js:60
  • nit: URL_REGEX greedily captures trailing punctuation (e.g. ) or . at end of sentence) - consider a negative lookbehind or trimming trailing non-URL chars - src/clients/jira-cloud-client.js:32
  • nit: sanitizeFilename strips ../ sequences but not standalone .. (no trailing slash) - edge case for directory traversal on Windows-style paths - src/clients/jira-cloud-client.js:124

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 10m 30s | Cost: $8.16 | Commit: 2541c41333939018ad13d14c8cdba405e63ee6a8
If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread packages/spacecat-shared-ticket-client/src/ticket-client-factory.js
Comment thread packages/spacecat-shared-ticket-client/package.json

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Approve - all four prior blocking findings are correctly fixed with adequate tests.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (23 files).

Previously flagged, now resolved

  • Secret path injection: buildSecretPath now validates both IDs as UUIDs before interpolation
  • Unused production dependencies: @adobe/fetch and @adobe/spacecat-shared-utils removed; c8, mocha, mocha-multi-reporters added to devDependencies
  • Uncapped Retry-After: MAX_WAIT_MS = 30_000 ceiling applied via Math.min
  • ClientRequestToken misuse: SHA-256 hash of full access token (64-char hex) replaces naive .slice(0, 32)
Non-blocking (4): minor issues and suggestions
  • nit: sanitizeFilename .replace(/\.\./g, '') strips ALL .. sequences including legitimate ones in filenames like report..final.pdf - stripping path separators (/ and \) instead would be more targeted - src/clients/jira-cloud-client.js:125
  • nit: UUID_REGEX comment says "Matches lowercase UUIDs" but the regex uses the /i flag accepting uppercase - align the comment with the code or remove the flag - src/ticket-client-factory.js:34
  • suggestion: validate process.env.NODE_ENV against an allowlist or /^[a-z]+$/ pattern in buildSecretPath for defense-in-depth - the other two path segments are UUID-validated but the env segment is not - src/ticket-client-factory.js:56
  • nit: URL_REGEX lookbehind will strip trailing ) from legitimate URLs with balanced parens like Wikipedia links https://en.wikipedia.org/wiki/Foo_(bar) - acceptable tradeoff for the Jira description use case but worth a code comment - src/clients/jira-cloud-client.js:30

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 5s | Cost: $6.08 | Commit: 0303efc11adae7029511b667a03e529fcdd7d5dc
If this code review was useful, please react with 👍. Otherwise, react with 👎.

@prithipalpatwal
prithipalpatwal force-pushed the feat/SITES-44690-ticket-client branch from 4a5adf2 to 4b583ae Compare June 23, 2026 15:52
@MysticatBot

Copy link
Copy Markdown

Mysticat review failed: Claude CLI crashed (exit 1): stderr= stdout={"type":"result","subtype":"error_max_budget_usd","duration_ms":6,"duration_api_ms":1560718,"is_error":true,"num_turns":1,"stop_reason":null,"session_id":"b18831f0-22a7-4862-a39b-cdbc7351939d","total_cost_usd":10.4506784,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tok

@prithipalpatwal
prithipalpatwal requested review from MysticatBot and removed request for MysticatBot June 23, 2026 18:10

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Request changes - prior blocking findings are fixed; a few remaining correctness issues in filename sanitization, environment validation, and token lifecycle handling.
Complexity: HIGH - large diff, new package scaffolding across 24 files.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (24 files).

Must fix before merge

  1. [Important] sanitizeFilename strips all .. sequences and allows path separators through - src/clients/jira-cloud-client.js:127 (details inline)
  2. [Important] process.env.NODE_ENV interpolated into SM secret path without validation - src/ticket-client-factory.js:56 (details inline)
  3. [Important] Token refresh 401 from Atlassian never triggers setRequiresReauth - src/credentials/oauth-credential-manager.js:84 (details inline)
  4. [Important] siteUrl validation uses string suffix matching instead of hostname parsing - src/clients/jira-cloud-client.js:97 (details inline)
Non-blocking (5): minor issues and suggestions
  • nit: index.test.js does not verify RateLimitAwareHttpClient export despite it being exported from src/index.js - test/index.test.js:22
  • nit: CLOUD_ID_REGEX is lowercase-only (no /i) while UUID_REGEX in the factory has /i - inconsistent casing contract - src/clients/jira-cloud-client.js:19 vs src/ticket-client-factory.js:34
  • nit: #buildAdfDescription JSDoc says "URLs are converted to clickable link nodes" but implementation emits plain-text nodes only - src/clients/jira-cloud-client.js:282
  • suggestion: add ticket-client to the Package Catalog's API clients row in the root CLAUDE.md so the discovery index stays current
  • nit: test/setup-env.js copyright header says 2026, rest of files say 2025 - test/setup-env.js:2

Previously flagged, now resolved

  • Secret path injection: buildSecretPath now validates both IDs as UUIDs before interpolation
  • Unused production dependencies: removed from dependencies; test tooling moved to devDependencies
  • Uncapped Retry-After: MAX_WAIT_MS = 30_000 ceiling applied via Math.min
  • ClientRequestToken misuse: SHA-256 hash of full access token replaces naive .slice(0, 32)

Comment thread packages/spacecat-shared-ticket-client/src/ticket-client-factory.js
Comment thread packages/spacecat-shared-ticket-client/src/clients/jira-cloud-client.js Outdated
@MysticatBot MysticatBot added the complexity:high High complexity PR label Jun 23, 2026

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Approve - all four prior blocking findings are correctly fixed with appropriate test coverage.
Complexity: HIGH - large diff; new package scaffolding with auth + secrets management.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (24 files).

Previously flagged, now resolved

  • sanitizeFilename now strips path separators (/, \) instead of all .. sequences - preserves legitimate filenames while neutralizing traversal
  • NODE_ENV removed from SM secret path entirely - account boundary provides env isolation, eliminating the unvalidated interpolation
  • Token refresh 401 now triggers setRequiresReauth via try/catch on #fetchNewTokens with .status property on the thrown error
  • siteUrl validation upgraded to new URL() hostname parsing - blocks path-based and subdomain-based spoofing attempts
Non-blocking (2): minor issues and suggestions
  • nit: UUID_REGEX comment says "Matches lowercase UUIDs" but the regex uses the /i flag accepting uppercase - align the comment or remove the flag for consistency with CLOUD_ID_REGEX - src/ticket-client-factory.js:34
  • nit: sanitizeFilename comment "Strip path separators" is on the line above the eslint-disable, making it visually ambiguous which .replace it describes - consider moving it directly above the regex - src/clients/jira-cloud-client.js:120

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 1s | Cost: $3.07 | Commit: 15094660ec79f5d55cbd5652798e3ee31763514f
If this code review was useful, please react with 👍. Otherwise, react with 👎.

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Request changes - TypeScript declarations are out of sync with the implementation after the optional-fields commit.
Complexity: HIGH - large diff; new package scaffolding across 24 files.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (24 files).

Must fix before merge

  1. [Important] TicketData interface missing priority, dueDate, components, parent fields - TypeScript consumers will get compile errors - src/index.d.ts:22 (details inline)
Non-blocking (6): minor issues and suggestions
  • suggestion: validate dueDate format (/^\d{4}-\d{2}-\d{2}$/) before passing to Jira - consistent with how cloudId, ticketKey, and organizationId are validated - src/clients/jira-cloud-client.js:160
  • suggestion: add a max-pages safety bound to listProjects pagination loop to prevent unbounded requests if Jira API misbehaves - src/clients/jira-cloud-client.js:112
  • nit: .nycrc.json sets branches: 100 but project standard is 97% branches per CLAUDE.md - stricter than required but diverges from other packages - packages/spacecat-shared-ticket-client/.nycrc.json:8
  • suggestion: update root CLAUDE.md and AGENTS.md Package Catalog to include ticket-client in the API clients row and increment the package count from 22 to 23 - both catalogs are now stale
  • nit: CLOUD_ID_REGEX uses /i (accepts uppercase) while UUID_REGEX in the factory does not - intentional per comments but a one-line note on CLOUD_ID_REGEX explaining why would prevent future fixes - src/clients/jira-cloud-client.js:20
  • nit: test/setup-env.js copyright header says 2026 while all other files say 2025 - test/setup-env.js:2

Previously flagged, now resolved

  • Secret path injection: buildSecretPath validates both IDs as UUIDs before interpolation
  • Unused production dependencies: removed; test tooling moved to devDependencies
  • Uncapped Retry-After: MAX_WAIT_MS = 30_000 ceiling applied via Math.min
  • ClientRequestToken misuse: SHA-256 hash of full access token replaces naive .slice(0, 32)
  • sanitizeFilename: now strips path separators instead of all .. sequences
  • NODE_ENV: removed from SM secret path entirely
  • Token refresh 401: now triggers setRequiresReauth
  • siteUrl validation: upgraded to new URL() hostname parsing

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 2m 6s | Cost: $9.66 | Commit: 0eb2ca8c8d6e49ca99458f3aeb10a92a97ad9c09
If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread packages/spacecat-shared-ticket-client/src/index.d.ts
@prithipalpatwal

Copy link
Copy Markdown
Contributor Author

Addressed review 4572484771 in 73f62962:

  • Added priority, dueDate, components, parent to TicketData in src/index.d.ts
  • Made description optional (matches implementation)
  • Added dueDate YYYY-MM-DD validation + test
  • Added listProjects max-pages safety bound (100)
  • Documented CLOUD_ID_REGEX /i rationale

79 tests passing, 100% coverage. CI green.

@prithipalpatwal
prithipalpatwal requested review from MysticatBot and removed request for MysticatBot June 25, 2026 17:38

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Approve - all prior blocking findings are correctly fixed; only non-blocking nits remain.
Complexity: HIGH - large diff; new package scaffolding across 24 files.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (24 files).

Non-blocking (3): minor issues and suggestions
  • suggestion: root CLAUDE.md and AGENTS.md Package Catalog still say "22 packages" and omit ticket-client from the API clients row - both are now factually stale after this PR - AGENTS.md:47
  • nit: dueDate validation uses an IIFE-inside-spread pattern that is inconsistent with the other optional fields (priority, components, parent all use plain conditional spread) - hoisting the guard before body construction would be more readable - src/clients/jira-cloud-client.js:214
  • suggestion: MAX_PAGES safety bound has no dedicated test proving the loop terminates at 100 when isLast is perpetually false - a small stub test would prevent accidental removal - src/clients/jira-cloud-client.js:252

Previously flagged, now resolved

  • TypeScript TicketData interface now includes priority, dueDate, components, parent; description made optional
  • dueDate format validated via regex before passing to Jira
  • listProjects pagination bounded by MAX_PAGES = 100
  • CLOUD_ID_REGEX /i flag rationale documented in comment

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 19s | Cost: $4.23 | Commit: 73f6296206f4286f1d8a3838fa33be2019ef5f66
If this code review was useful, please react with 👍. Otherwise, react with 👎.

@prithipalpatwal
prithipalpatwal force-pushed the feat/SITES-44690-ticket-client branch from 661f5a7 to d7db958 Compare June 29, 2026 20:24
prithipalpatwal and others added 3 commits July 2, 2026 22:32
…bber

- I1: #writeReauthFlag checks for fresh tokens before writing — prevents
  clobbering a concurrent Lambda's valid tokens with requiresReauth: true
- M3: Validate summary is non-empty before API call — avoids round-trip
  with non-descriptive Jira 400
- M4: Validate labels contain no whitespace — Jira rejects labels with
  spaces but the error does not indicate which field failed
- M7: Validate SM secret has accessToken string — surfaces corrupt secret
  early instead of sending 'Bearer undefined' to Jira

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nput cap

- #readSecret: allow requiresReauth secrets without accessToken — admin
  tools may write { requiresReauth: true } as a minimal payload; the old
  validation threw a confusing error instead of surfacing REQUIRES_REAUTH
- markdownToAdf: use Buffer.byteLength for the 1MB input cap instead of
  String.length (UTF-16 code units) — emoji-dense input could exceed 1MB
  in UTF-8 bytes while appearing under the cap in code units. Binary
  search finds the code-point boundary that fits within the byte limit
  without splitting surrogate pairs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spread to code points before slicing to avoid splitting surrogate pairs
in emoji filenames. Consistent with sanitizeSummary which already uses
the same [...spread].slice().join('') pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Approve - no new issues since the last approval; the 12 intervening commits are merge-from-main only.
Complexity: HIGH - large diff; new package scaffolding across 28 files with auth + secrets management.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (28 files).
Note: Recommend a human read before merge - this change modifies the root AGENTS.md (architectural significance). The bot review is a complement to, not a replacement for, a human read here.

Non-blocking (2): minor issues and suggestions
  • nit: AGENTS.md Architecture Overview header still says "23 packages" but the CLAUDE.md catalog row lists more client names (mac-giver-client, project-engine-client, user-manager-client) that AGENTS.md omits - the two catalogs are diverging in which clients they enumerate - AGENTS.md:47 vs CLAUDE.md:74
  • suggestion: siteUrl validation permits bare atlassian.net hostname (no subdomain required) - display-only so no security impact, but adding || siteUrlParsed.hostname === 'atlassian.net' to the rejection would match the documented *.atlassian.net contract - src/clients/jira-cloud-client.js:88

Previously flagged, now resolved

  • Secret path injection: buildSecretPath validates both IDs as UUIDs before interpolation
  • Unused production dependencies: removed; test tooling moved to devDependencies
  • Uncapped Retry-After: MAX_WAIT_MS = 30_000 ceiling applied via Math.min
  • ClientRequestToken misuse: SHA-256 hash of full access token replaces naive .slice(0, 32)
  • sanitizeFilename: now strips path separators instead of all .. sequences
  • NODE_ENV: removed from SM secret path entirely
  • Token refresh 401: now triggers setRequiresReauth with concurrent-race recovery
  • siteUrl validation: upgraded to new URL() hostname parsing
  • TypeScript TicketData interface: includes priority, dueDate, components, parent; description made optional
  • dueDate validation: format validated via regex + calendar round-trip (Date.UTC)
  • listProjects pagination: bounded by MAX_PAGES = 100 with dedicated test
  • AGENTS.md and CLAUDE.md: updated to 23 packages with ticket-client in API clients row
  • ADF link nodes validate href scheme via allowlist (https/http/mailto)
  • #writeReauthFlag re-reads secret inside retry loop; clock.restore() moved to afterEach
  • parent field validated against TICKET_KEY_REGEX
  • Unwrapped httpClient passed to CredentialManagerFactory (auth endpoint has different rate-limit semantics)
  • TypeScript declarations expose refreshAuthHeaders and forceRefreshAuthHeaders
  • redirect: 'error' on credential-bearing fetches
  • XSS link-scheme test covers javascript: and data: schemes with mixed-case coverage
  • ADF DoS: depth guard (MAX_DEPTH=20) + 1 MB input cap + try/catch fallback
  • In-process Promise locks serialize concurrent same-Lambda callers
  • #withAuthRetry 401 path uses getAuthHeaders(true) bypassing TTL cache
  • .nycrc.json branches threshold aligned to repo standard (97%)
  • SM write failure no longer escalates to requiresReauth (returns valid token, logs warning)
  • lint violations fixed (CI passing)

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 16s | Cost: $9.13 | Commit: c573580a1a3858d372d22f36e9cd4b266c771a4e
If this code review was useful, please react with 👍. Otherwise, react with 👎.

prithipalpatwal and others added 9 commits July 2, 2026 23:34
…tBody from createTicket

SRP fix: createTicket() was mixing input validation, Jira field mapping,
and HTTP orchestration in one 83-line method. Extract two private methods
so each has a single reason to change.

No behavior change. 198 tests pass, 100% line/statement coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Empirically verified: Atlassian returns HTTP 403 with error body
{"error":"unauthorized_client"} for both:
  - Expired refresh token reuse window (>10 min)
  - App revoked by the user

Neither scenario was in GRANT_ERRORS, so effectiveStatus stayed 403
and the err.status === 401 guard in #doForceRefreshAuthHeaders never
fired — reauth recovery was silently skipped.

Fix: add 'unauthorized_client' to GRANT_ERRORS so it maps to synthetic
401, triggering #recoverFromAtlassian401() and requiresReauth write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… token behavior

Update #recoverFromAtlassian401 and class-level JSDoc to reflect actual
observed behavior from live testing:

- HTTP status is 403 (not 400/401) with error unauthorized_client
- "ALL tokens returned unauthorized_client" was imprecise: only the
  ORIGINAL token was tested post-window; derived tokens retain
  independent 90-day TTL
- Document all four scenarios that produce 403 unauthorized_client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…or code

Previously #fetchNewTokens mapped grant-level Atlassian errors to a
synthetic err.status=401 so callers could check err.status === 401.
This was fragile — callers depended on a manufactured HTTP status rather
than a meaningful semantic code.

Replace with err.code = 'GRANT_REVOKED' thrown for all grant-level
failures. Callers now check err.code === 'GRANT_REVOKED' which is:
  - explicit about the failure reason
  - independent of Atlassian's HTTP status (which varies: 400/403)
  - immune to future status code changes from Atlassian

Rename #recoverFromAtlassian401 → #recoverFromRevokedGrant to match.
Update tests to use realistic 403/unauthorized_client mocks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rationale

Add empirically verified scenario table to class JSDoc:
  Token >10 min (stale SM) | 403 unauthorized_client | User must reconnect
  App revoked              | 403 unauthorized_client | User must reconnect

Clarify #recoverFromRevokedGrant:
- Explain the rotation-race case (200ms wait IS needed for Lambda B
  to find Lambda A's fresh tokens after a concurrent forceRefresh)
- Explain the genuinely-dead-grant case (both scenarios → requiresReauth)
- Fix stale "Atlassian 401" log messages to say GRANT_REVOKED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rically verified

unauthorized_client at HTTP 403 — verified in live test today.
invalid_grant, invalid_token, access_denied — assumed from RFC 6749 §5.2,
not empirically tested. Note this distinction in the comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… unauthorized_client empirically observed

Empirical testing confirmed Atlassian returns HTTP 403 {"error":"unauthorized_client"}
as a catch-all for ALL refresh token failures. RFC 6749 §5.2 codes invalid_grant,
invalid_token, and access_denied were never observed at the Atlassian token endpoint.

- Replace GRANT_ERRORS Set with direct `errorCode === 'unauthorized_client'` check
- Remove `invalid_grant` test (code path can never be reached)
- Expand scenario table in class JSDoc with all empirically verified failure cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… as GRANT_REVOKED

Official Atlassian 3LO docs document 403 invalid_grant for expired/revoked tokens.
Empirical testing returned 403 unauthorized_client for all manual test scenarios.
Both codes now trigger GRANT_REVOKED → reauth recovery path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ios in JSDoc

Replace vague "all manual test scenarios" with the specific four cases tested:
token reused past 10-min window, app revoked, malformed token, random/empty string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Approve - all prior blocking findings are correctly fixed; only non-blocking nits remain after 9 new commits.
Complexity: HIGH - large diff; new package scaffolding across 28 files with auth + secrets management.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (28 files).
Note: Recommend a human read before merge - this change modifies the root AGENTS.md (architectural significance). The bot review is a complement to, not a replacement for, a human read here.

Non-blocking (4): minor issues and suggestions
  • nit: code: isGrantRevoked ? 'GRANT_REVOKED' : undefined attaches an explicit undefined-valued property to non-grant errors; use conditional spread instead (...(isGrantRevoked && { code: 'GRANT_REVOKED' })) to avoid noise in structured logging - src/credentials/oauth-credential-manager.js:407
  • nit: test descriptions still say "non-401" (e.g. 'propagates non-401 Atlassian errors...') but the production code no longer checks for 401 from the token endpoint; rename to "non-grant" for clarity - test/oauth-credential-manager.test.js:665
  • suggestion: add a negative test for HTTP 403 with an unrecognized error code (e.g. access_denied) to document that only invalid_grant and unauthorized_client trigger GRANT_REVOKED - the code is correct but the contract is not pinned by a test - test/oauth-credential-manager.test.js
  • nit: README states "100% statement, branch, function, and line coverage is enforced" but .nycrc.json correctly implements 97% branches per repo convention - align the README text - packages/spacecat-shared-ticket-client/README.md:395

Previously flagged, now resolved

  • Secret path injection: buildSecretPath validates both IDs as UUIDs before interpolation
  • Unused production dependencies: removed; test tooling moved to devDependencies
  • Uncapped Retry-After: MAX_WAIT_MS = 30_000 ceiling applied via Math.min
  • ClientRequestToken misuse: SHA-256 hash of full access token replaces naive .slice(0, 32)
  • sanitizeFilename: now strips path separators instead of all .. sequences
  • NODE_ENV: removed from SM secret path entirely
  • Token refresh 401: now triggers setRequiresReauth with concurrent-race recovery
  • siteUrl validation: upgraded to new URL() hostname parsing
  • TypeScript TicketData interface: includes priority, dueDate, components, parent; description made optional
  • dueDate validation: format validated via regex + calendar round-trip (Date.UTC)
  • listProjects pagination: bounded by MAX_PAGES = 100 with dedicated test
  • AGENTS.md and CLAUDE.md: updated to 23 packages with ticket-client in API clients row
  • ADF link nodes validate href scheme via allowlist (https/http/mailto)
  • #writeReauthFlag re-reads secret inside retry loop; clock.restore() moved to afterEach
  • parent field validated against TICKET_KEY_REGEX
  • Unwrapped httpClient passed to CredentialManagerFactory (auth endpoint has different rate-limit semantics)
  • TypeScript declarations expose refreshAuthHeaders and forceRefreshAuthHeaders
  • redirect: 'error' on credential-bearing fetches
  • XSS link-scheme test covers javascript: and data: schemes with mixed-case coverage
  • ADF DoS: depth guard (MAX_DEPTH=20) + 1 MB input cap + try/catch fallback
  • In-process Promise locks serialize concurrent same-Lambda callers
  • #withAuthRetry 401 path uses getAuthHeaders(true) bypassing TTL cache
  • .nycrc.json branches threshold aligned to repo standard (97%)
  • SM write failure no longer escalates to requiresReauth (returns valid token, logs warning)
  • Lint violations fixed (CI passing)
  • SRP extraction: createTicket split into #validateTicketInput + #buildTicketBody
  • Grant error handling: semantic err.code = 'GRANT_REVOKED' replaces synthetic status mapping; unauthorized_client added as recognized grant error

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 23s | Cost: $6.57 | Commit: 7f55dbb47118c5569034e9628c23771e9d85421e
If this code review was useful, please react with 👍. Otherwise, react with 👎.

…review

1. Use conditional spread instead of explicit undefined for non-grant errors
   (avoids noise in structured logging)
2. Rename test descriptions from "non-401" to "non-grant" — code no longer
   checks for 401 from the token endpoint
3. Add negative test for access_denied (403) to document that only
   invalid_grant and unauthorized_client trigger GRANT_REVOKED
4. Align README coverage claim with .nycrc.json (97% branches, not 100%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@MysticatBot MysticatBot 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.

Hey @prithipalpatwal,

Verdict: Approve - all prior non-blocking nits are correctly addressed; no new issues introduced.
Complexity: HIGH - large diff; new package scaffolding across 28 files with auth + secrets management.
Changes: adds the spacecat-shared-ticket-client package with Jira Cloud integration via OAuth 2.0 3LO, including rate-limit-aware HTTP, credential management, ADF formatting, and attachment upload (28 files).
Note: Recommend a human read before merge - this change modifies the root AGENTS.md (architectural significance). The bot review is a complement to, not a replacement for, a human read here.

Non-blocking (0): no issues found

No new findings on this commit.

Previously flagged, now resolved

  • README coverage statement corrected to match repo convention (97% branch coverage)
  • Conditional spread replaces explicit undefined-valued code property on non-grant errors
  • Negative test added for unrecognized error codes (403 access_denied does not trigger GRANT_REVOKED)
  • Test descriptions renamed from "non-401" to "non-grant" for accuracy

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 0m 45s | Cost: $2.35 | Commit: 6a9f79b7eac636d95917c877bbf7f04965437db7
If this code review was useful, please react with 👍. Otherwise, react with 👎.

@ravverma ravverma 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.

Hey @prithipalpatwal,

Thanks for the thorough turnaround - this closes the loop on the prior review. I re-verified each earlier finding against the current code. Every blocking item is resolved, so this supersedes the earlier request for changes.

Previously flagged, now resolved

  • Token expiry detection: #isExpired now treats a non-finite expiresAt as expired, and #readSecret validates the secret structure, so a corrupt/partial secret can no longer serve a stale token forever.
  • Rotated-token safety: an SM write failure now surfaces as SM_WRITE_FAILED and returns the freshly-issued Atlassian token instead of force-marking requiresReauth, so a transient SM blip no longer bricks the connection.
  • ADF DoS: depth cap (20), 1 MB input cap, and a try/catch plain-text fallback make the converter crash-safe on untrusted input.
  • Duplicate-ticket risk: non-idempotent methods are excluded from 5xx/network retry, and the non-idempotency is documented on createTicket.
  • OAuth error handling: the token-endpoint error body is now parsed, and both invalid_grant and unauthorized_client (403) route to the reauth path.
  • Empty code fence, multibyte summary truncation, redirect pinning (redirect: 'error'), per-attempt fetch timeout, idempotent 5xx/network retry, SM read caching, failure-path logging, README backoff numbers, listProjects return type, and summary-presence validation are all addressed.

I also checked the refactored concurrency and caching code (in-process refresh locks, secret cache) for regressions and found none - the locks clear in finally and the cache is invalidated before every write.

Non-blocking follow-ups

These are documentation/contract cleanups, not defects. Fine to address in a follow-up.

  1. The createTicket JSDoc still contradicts what the code does. jira-cloud-client.js:164 - the doc says "Plain-text description (no ADF markup)... untrusted suggestion content is never rendered as structured markup (spec 13)," and the class doc at line 132 says "plain-text leaf nodes only." But the implementation runs markdownToAdf(description) and the README (lines 81-99) documents and encourages full markdown - bold, links, tables, code blocks. Now that raw HTML tokens are stripped and link schemes are allowlisted, this is no longer an injection risk, but the security contract stated in the JSDoc is simply false: structured markup is rendered. A consumer trusting that comment will misjudge what reaches Jira. Suggestion: reconcile the JSDoc and class doc with the README/code - either drop the "plain-text only / spec 13" language or state explicitly that markdown is parsed with HTML stripped and link schemes allowlisted.

  2. The token-refresh handshake is undocumented and not reachable through the factory. getAuthHeaders is pure-read and throws TOKEN_REFRESH_REQUIRED on expiry; the only methods that actually refresh (refreshAuthHeaders/forceRefreshAuthHeaders) live on OAuthCredentialManager, which TicketClientFactory.create (ticket-client-factory.js:96) does not expose on the returned client. The README Error Handling section (lines 160-175) covers 429 and requires re-authorization but never mentions TOKEN_REFRESH_REQUIRED or how to complete the refresh-and-retry loop. If the intended model is that the consuming service constructs its own OAuthCredentialManager (which is exported), document that explicitly with an end-to-end example; otherwise expose the credential manager on the factory-created client.

  3. MAX_WAIT_MS is applied only to the Retry-After branch, not the exponential-backoff fallback (rate-limit-aware-http-client.js:231). It stays under the ceiling today only because MAX_RETRIES=4 and BASE_BACKOFF_MS=2000; the "hard ceiling on any single retry wait" comment (lines 20-21) will silently stop holding if either constant grows. Wrap the fallback return in Math.min(..., MAX_WAIT_MS). There is also still no aggregate retry deadline across the nested #withAuthRetry + 429 loop, though the new per-attempt 30 s timeout bounds the worst case considerably.

Assessment

Ready to merge? Yes. Every blocking item from the prior review is resolved and I found no regressions in the refactored code. The three items above are documentation-accuracy and defensive-hardening cleanups, not exploitable defects, and can be handled as follow-ups. Approving.

@prithipalpatwal
prithipalpatwal enabled auto-merge (squash) July 3, 2026 08:52
@prithipalpatwal
prithipalpatwal merged commit 75e9204 into main Jul 3, 2026
5 checks passed
@prithipalpatwal
prithipalpatwal deleted the feat/SITES-44690-ticket-client branch July 3, 2026 08:55
solaris007 pushed a commit that referenced this pull request Jul 6, 2026
## @adobe/spacecat-shared-ticket-client-v1.0.0 (2026-07-06)

### Features

* add spacecat-shared-ticket-client package with Jira Cloud integration (SITES-44690) ([#1701](#1701)) ([75e9204](75e9204))

### Bug Fixes

* **ticket-client:** mark package private + sync lockfile to unblock main release pipeline ([#1782](#1782)) ([b338c7e](b338c7e))
@solaris007

Copy link
Copy Markdown
Member

🎉 This PR is included in version @adobe/spacecat-shared-ticket-client-v1.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

prithipalpatwal added a commit that referenced this pull request Jul 7, 2026
…#1783)

## Summary

- Adds `tokenRefreshedAt` (ISO 8601 timestamp) to the SM secret payload
written by `OAuthCredentialManager#writeTokens()` on inline token
refresh
- Previously, auth-service wrote this field at OAuth callback time, but
ticket-client's inline refresh overwrote the secret without it — the
timestamp disappeared after the first inline refresh

## Context

Found during alignment review of auth-service PR #595 against
ticket-client PR #1701. The field is observability-only (not read by any
code path), but ops tooling that inspects SM secrets to check token
freshness loses the timestamp.

## Test plan

- [x] Existing test updated to verify `tokenRefreshedAt` is present and
recent in written SM payload
- [x] 200 tests passing, 100% coverage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants