@goodandready/dsh-github-ops 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  Notable changes to `@goodandready/dsh-github-ops`.
4
4
 
5
+ ## 0.2.4
6
+
7
+ ### Fixed
8
+ - Fixed GitHub client cache holding onto stale credentials when rotated with a token of the exact same length: cache key now incorporates a non-reversible sha256 token fingerprint (#38).
9
+
5
10
  ## 0.2.3
6
11
 
7
12
  ### Added
package/lib/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  // Access resolution across the sources a GitHub user actually has.
2
3
  //
3
4
  // Order (tokenSource: "auto"): the DSH credentials service, then the environment variable of
@@ -69,3 +70,66 @@ export async function resolveAccess({ credentials, tokenEnv, tokenSource = 'auto
69
70
  }
70
71
  return { value: '', source: '', guidance: NO_ACCESS_GUIDANCE }
71
72
  }
73
+ /**
74
+ * Deterministic non-reversible token fingerprint for client cache keys:
75
+ * differentiates tokens of identical length without leaking token content into logs or keys.
76
+ */
77
+ export function tokenFingerprint(value) {
78
+ if (!value || typeof value !== 'string') return ''
79
+ return createHash('sha256').update(value).digest('hex').slice(0, 16)
80
+ }
81
+
82
+ /**
83
+ * Cache key for shared GitHub client instances.
84
+ * Incorporates the token fingerprint (not length) so rotation between equal-length tokens
85
+ * creates a new client and uses the new Bearer immediately.
86
+ */
87
+ export function clientCacheKey(access, cfg = {}) {
88
+ return [
89
+ access?.source || '',
90
+ tokenFingerprint(access?.value),
91
+ cfg?.baseUrl || '',
92
+ cfg?.timeoutMs || '',
93
+ cfg?.cacheTtlMs || '',
94
+ cfg?.maxRetries ?? '',
95
+ ].join('|')
96
+ }
97
+
98
+ /**
99
+ * Manages the shared GitHub client instance:
100
+ * caches the client while the key matches; recreates it when the token or config changes.
101
+ */
102
+ export function createClientHolder({ createClient, signalProvider } = {}) {
103
+ let sharedClient = null
104
+ let sharedKey = ''
105
+
106
+ return {
107
+ get(access, cfg = {}) {
108
+ if (!access || !access.value) {
109
+ throw new Error(`GitHub access is not configured: ${access?.guidance || NO_ACCESS_GUIDANCE}`)
110
+ }
111
+ const key = clientCacheKey(access, cfg)
112
+ if (!sharedClient || sharedKey !== key) {
113
+ if (typeof createClient !== 'function') {
114
+ throw new Error('createClient factory must be a function')
115
+ }
116
+ sharedClient = createClient({
117
+ token: access.value,
118
+ baseUrl: cfg.baseUrl || 'https://api.github.com',
119
+ timeoutMs: Number(cfg.timeoutMs) || 30000,
120
+ cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
121
+ maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
122
+ signalProvider,
123
+ })
124
+ sharedKey = key
125
+ }
126
+ return sharedClient
127
+ },
128
+ currentKey: () => sharedKey,
129
+ currentClient: () => sharedClient,
130
+ reset: () => {
131
+ sharedClient = null
132
+ sharedKey = ''
133
+ },
134
+ }
135
+ }
package/lib/index.js CHANGED
@@ -15,7 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
15
15
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
16
16
 
17
17
  import { createGitHubClient, parseRepoSpec, describeError, GitHubError } from './github.js'
18
- import { resolveAccess, NO_ACCESS_GUIDANCE } from './auth.js'
18
+ import { resolveAccess, NO_ACCESS_GUIDANCE, createClientHolder } from './auth.js'
19
19
  import { resolveRepoContext } from './repo-context.js'
20
20
  import { presentationFor } from './present.js'
21
21
  import { createRecords, startReviewJob } from './jobs.js'
@@ -233,39 +233,20 @@ export function apply(ctx, rawConfig) {
233
233
  }
234
234
 
235
235
  // One client per configuration: the read cache lives in it, and the signal for the
236
- // current tool call arrives through the async context.
237
- let sharedClient = null
238
- let sharedKey = ''
236
+ // current tool call arrives through the async context. Token rotation immediately recreates
237
+ // the client even when the new token has the exact same length.
238
+ const clientHolder = createClientHolder({
239
+ createClient: createGitHubClient,
240
+ signalProvider: () => {
241
+ const store = callContext.getStore()
242
+ return store && store.signal ? store.signal : undefined
243
+ },
244
+ })
239
245
 
240
246
  async function client() {
241
247
  const cfg = liveConfig()
242
248
  const access = await resolveToken()
243
- if (!access.value) {
244
- throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
245
- }
246
- const key = [
247
- access.source,
248
- access.value.length,
249
- cfg.baseUrl || '',
250
- cfg.timeoutMs || '',
251
- cfg.cacheTtlMs || '',
252
- cfg.maxRetries ?? '',
253
- ].join('|')
254
- if (!sharedClient || sharedKey !== key) {
255
- sharedClient = createGitHubClient({
256
- token: access.value,
257
- baseUrl: cfg.baseUrl || 'https://api.github.com',
258
- timeoutMs: Number(cfg.timeoutMs) || 30000,
259
- cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
260
- maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
261
- signalProvider: () => {
262
- const store = callContext.getStore()
263
- return store && store.signal ? store.signal : undefined
264
- },
265
- })
266
- sharedKey = key
267
- }
268
- return sharedClient
249
+ return clientHolder.get(access, cfg)
269
250
  }
270
251
 
271
252
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
5
5
  "license": "MIT",
6
6
  "type": "module",