@knpkv/atlassian-common 1.2.0 → 1.4.1

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +6 -0
  3. package/dist/Brand.d.ts.map +1 -1
  4. package/dist/Hash.d.ts.map +1 -1
  5. package/dist/ast/Document.d.ts +3 -19
  6. package/dist/ast/Document.d.ts.map +1 -1
  7. package/dist/ast/Document.js +4 -3
  8. package/dist/ast/Document.js.map +1 -1
  9. package/dist/ast/MacroNode.d.ts +3 -3
  10. package/dist/attachments.d.ts.map +1 -1
  11. package/dist/attachments.js +2 -6
  12. package/dist/attachments.js.map +1 -1
  13. package/dist/auth/OAuthEndpoints.d.ts +23 -0
  14. package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
  15. package/dist/auth/OAuthEndpoints.js +36 -0
  16. package/dist/auth/OAuthEndpoints.js.map +1 -1
  17. package/dist/auth/OAuthErrors.d.ts +22 -0
  18. package/dist/auth/OAuthErrors.d.ts.map +1 -1
  19. package/dist/auth/OAuthErrors.js +1 -1
  20. package/dist/auth/OAuthErrors.js.map +1 -1
  21. package/dist/auth/OAuthOperations.d.ts +15 -1
  22. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  23. package/dist/auth/OAuthOperations.js +23 -4
  24. package/dist/auth/OAuthOperations.js.map +1 -1
  25. package/dist/auth/OAuthResponseSchemas.d.ts +11 -0
  26. package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -1
  27. package/dist/auth/OAuthResponseSchemas.js +11 -0
  28. package/dist/auth/OAuthResponseSchemas.js.map +1 -1
  29. package/dist/auth/index.d.ts +2 -2
  30. package/dist/auth/index.d.ts.map +1 -1
  31. package/dist/auth/index.js +5 -3
  32. package/dist/auth/index.js.map +1 -1
  33. package/dist/bin.js +2 -2
  34. package/dist/bin.js.map +1 -1
  35. package/dist/config/AuthProfiles.d.ts +140 -175
  36. package/dist/config/AuthProfiles.d.ts.map +1 -1
  37. package/dist/config/AuthProfiles.js +2 -1
  38. package/dist/config/AuthProfiles.js.map +1 -1
  39. package/dist/config/ConfigPaths.d.ts.map +1 -1
  40. package/dist/config/ProfileManager.d.ts +15 -2
  41. package/dist/config/ProfileManager.d.ts.map +1 -1
  42. package/dist/config/ProfileManager.js +46 -6
  43. package/dist/config/ProfileManager.js.map +1 -1
  44. package/dist/config/TokenStorage.d.ts +8 -0
  45. package/dist/config/TokenStorage.d.ts.map +1 -1
  46. package/dist/config/TokenStorage.js +17 -7
  47. package/dist/config/TokenStorage.js.map +1 -1
  48. package/dist/config/index.d.ts +2 -2
  49. package/dist/config/index.d.ts.map +1 -1
  50. package/dist/config/index.js +2 -2
  51. package/dist/config/index.js.map +1 -1
  52. package/dist/internal/legacyWallClock.d.ts +11 -0
  53. package/dist/internal/legacyWallClock.d.ts.map +1 -0
  54. package/dist/internal/legacyWallClock.js +11 -0
  55. package/dist/internal/legacyWallClock.js.map +1 -0
  56. package/dist/serializers/MarkdownSerializer.d.ts.map +1 -1
  57. package/dist/serializers/MarkdownSerializer.js.map +1 -1
  58. package/package.json +7 -7
  59. package/src/ast/Document.ts +5 -4
  60. package/src/attachments.ts +2 -1
  61. package/src/auth/OAuthEndpoints.ts +38 -0
  62. package/src/auth/OAuthErrors.ts +23 -1
  63. package/src/auth/OAuthOperations.ts +44 -6
  64. package/src/auth/OAuthResponseSchemas.ts +12 -0
  65. package/src/auth/index.ts +6 -1
  66. package/src/bin.ts +4 -5
  67. package/src/config/AuthProfiles.ts +4 -2
  68. package/src/config/ProfileManager.ts +56 -7
  69. package/src/config/TokenStorage.ts +24 -8
  70. package/src/config/index.ts +2 -0
  71. package/src/internal/legacyWallClock.ts +10 -0
  72. package/test/ApiUpdateWorkflows.test.ts +80 -0
  73. package/test/OAuthEndpoints.test.ts +23 -0
  74. package/test/ProfileManager.test.ts +61 -0
  75. package/test/PublicExports.test.ts +16 -0
@@ -23,11 +23,13 @@ import * as Effect from "effect/Effect"
23
23
  import * as Schema from "effect/Schema"
24
24
  import { HttpClient, HttpClientRequest } from "effect/unstable/http"
25
25
  import { type OAuthConfig, type OAuthToken } from "../config/OAuthSchemas.js"
26
+ import { unsafeCurrentTimeMillis } from "../internal/legacyWallClock.js"
26
27
  import { ME_URL, RESOURCES_URL, REVOKE_URL, TOKEN_URL } from "./OAuthEndpoints.js"
27
28
  import { OAuthError } from "./OAuthErrors.js"
28
29
  import {
29
30
  type AccessibleResource,
30
31
  AccessibleResourceSchema,
32
+ TokenErrorSchema,
31
33
  type TokenResponse,
32
34
  TokenResponseSchema,
33
35
  type UserInfo,
@@ -48,6 +50,15 @@ export interface ExchangeCodeOptions {
48
50
  readonly codeVerifier?: string | undefined
49
51
  }
50
52
 
53
+ interface AuthorizationCodeTokenBody {
54
+ readonly grant_type: string
55
+ readonly client_id: string
56
+ readonly client_secret: string
57
+ readonly code: string
58
+ readonly redirect_uri: string
59
+ code_verifier?: string
60
+ }
61
+
51
62
  /**
52
63
  * Exchange authorization code for tokens.
53
64
  *
@@ -57,10 +68,10 @@ export const exchangeCodeForTokens = (
57
68
  code: string,
58
69
  config: OAuthConfig,
59
70
  options: ExchangeCodeOptions
60
- ): Effect.Effect<TokenResponse, OAuthError, HttpClient.HttpClient> =>
71
+ ) =>
61
72
  Effect.gen(function*() {
62
73
  const httpClient = yield* HttpClient.HttpClient
63
- const tokenBody: Record<string, string> = {
74
+ const tokenBody: AuthorizationCodeTokenBody = {
64
75
  grant_type: "authorization_code",
65
76
  client_id: config.clientId,
66
77
  client_secret: config.clientSecret,
@@ -191,8 +202,21 @@ export const refreshToken = (
191
202
  const text = yield* response.text.pipe(
192
203
  Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
193
204
  )
205
+ // The OAuth `error` code is what distinguishes "this grant is spent" from
206
+ // "your request was malformed", and callers key credential deletion off
207
+ // it. A body that is not JSON, or not shaped like an OAuth error, simply
208
+ // leaves it absent — which callers read as "no verdict", not "rejected".
209
+ const errorCode = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(TokenErrorSchema))(text).pipe(
210
+ Effect.map((decoded) => decoded.error),
211
+ Effect.catch(() => Effect.succeed(undefined))
212
+ )
194
213
  return yield* Effect.fail(
195
- new OAuthError({ step: "refresh", cause: `HTTP ${response.status}: ${text}` })
214
+ new OAuthError({
215
+ step: "refresh",
216
+ cause: `HTTP ${response.status}: ${text}`,
217
+ status: response.status,
218
+ ...(!(errorCode === undefined) && { errorCode })
219
+ })
196
220
  )
197
221
  }
198
222
 
@@ -260,14 +284,15 @@ export const revokeToken = (
260
284
  *
261
285
  * @category Utilities
262
286
  */
263
- export const buildOAuthToken = (
287
+ export const buildOAuthTokenAt = (
264
288
  tokenResponse: TokenResponse,
265
289
  site: AccessibleResource,
266
- user: UserInfo
290
+ user: UserInfo,
291
+ nowMs: number
267
292
  ): OAuthToken => ({
268
293
  access_token: tokenResponse.access_token,
269
294
  refresh_token: tokenResponse.refresh_token,
270
- expires_at: Date.now() + tokenResponse.expires_in * 1000,
295
+ expires_at: nowMs + tokenResponse.expires_in * 1000,
271
296
  scope: tokenResponse.scope,
272
297
  cloud_id: site.id,
273
298
  site_url: site.url,
@@ -277,3 +302,16 @@ export const buildOAuthToken = (
277
302
  email: user.email
278
303
  }
279
304
  })
305
+
306
+ /**
307
+ * Build an OAuth token using the host clock.
308
+ *
309
+ * @deprecated Effect workflows should call {@link buildOAuthTokenAt} with
310
+ * `Clock.currentTimeMillis`. This wrapper preserves the existing synchronous
311
+ * public API until the next major release.
312
+ */
313
+ export const buildOAuthToken = (
314
+ tokenResponse: TokenResponse,
315
+ site: AccessibleResource,
316
+ user: UserInfo
317
+ ): OAuthToken => buildOAuthTokenAt(tokenResponse, site, user, unsafeCurrentTimeMillis())
@@ -11,6 +11,18 @@
11
11
  */
12
12
  import * as Schema from "effect/Schema"
13
13
 
14
+ /**
15
+ * OAuth 2.0 error response body (RFC 6749 §5.2).
16
+ *
17
+ * Only `error` is required; providers vary on the rest, so nothing else is
18
+ * modelled — the code is the part callers act on.
19
+ *
20
+ * @category Schema
21
+ */
22
+ export const TokenErrorSchema = Schema.Struct({
23
+ error: Schema.String
24
+ })
25
+
14
26
  /**
15
27
  * Schema for OAuth2 token response from Atlassian.
16
28
  *
package/src/auth/index.ts CHANGED
@@ -4,14 +4,18 @@
4
4
  * @module
5
5
  */
6
6
 
7
- // Endpoints and scopes
7
+ // Endpoints and legacy public scope sets. Control Center owns a separate
8
+ // least-privilege release-publication set; proposal-only consumers can use
9
+ // JIRA_PROPOSAL_SCOPES and other write-capable consumers can use JIRA_SCOPES.
8
10
  export {
9
11
  AUTH_URL,
10
12
  buildAuthUrl,
11
13
  type BuildAuthUrlOptions,
12
14
  computeCodeChallenge,
15
+ CONFLUENCE_FOLDER_SCOPES,
13
16
  CONFLUENCE_SCOPES,
14
17
  generateCodeVerifier,
18
+ JIRA_PROPOSAL_SCOPES,
15
19
  JIRA_SCOPES,
16
20
  ME_URL,
17
21
  RESOURCES_URL,
@@ -25,6 +29,7 @@ export { AuthMissingError, OAuthError, OAuthNotConfiguredError, type OAuthStep }
25
29
  // Operations
26
30
  export {
27
31
  buildOAuthToken,
32
+ buildOAuthTokenAt,
28
33
  exchangeCodeForTokens,
29
34
  type ExchangeCodeOptions,
30
35
  getAccessibleResources,
package/src/bin.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { NodeFileSystem, NodeHttpClient, NodePath, NodeRuntime, NodeServices, NodeStdio } from "@effect/platform-node"
2
+ import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node"
3
3
  import * as Console from "effect/Console"
4
4
  import * as Effect from "effect/Effect"
5
5
  import * as Layer from "effect/Layer"
@@ -99,17 +99,16 @@ const program = Effect.gen(function*() {
99
99
  const args = yield* stdio.args
100
100
  return yield* cli(args)
101
101
  }).pipe(
102
- Effect.provide(NodeStdio.layer),
103
102
  Effect.provide(
104
103
  Layer.mergeAll(
105
104
  NodeServices.layer,
106
- NodeFileSystem.layer,
107
- NodePath.layer,
108
105
  NodeHttpClient.layerFetch,
109
106
  HomeDirectoryLive
110
107
  )
111
108
  ),
112
- Effect.catch((error: unknown) => Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error))))
109
+ Effect.catch(<UnparsedInput>(error: UnparsedInput) =>
110
+ Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error)))
111
+ )
113
112
  )
114
113
 
115
114
  NodeRuntime.runMain(program, { disableErrorReporting: true })
@@ -71,9 +71,11 @@ export type AuthProfilesFile = Schema.Schema.Type<typeof AuthProfilesFileSchema>
71
71
 
72
72
  const emptyProfiles = (): AuthProfilesFile => ({ profiles: [] })
73
73
 
74
- const parseJsonOrNull = (content: string): unknown | null => {
74
+ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
75
+
76
+ const parseJsonOrNull = (content: string): Schema.Json | null => {
75
77
  try {
76
- return JSON.parse(content)
78
+ return decodeJson(content)
77
79
  } catch {
78
80
  return null
79
81
  }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @module
5
5
  */
6
+ import * as Clock from "effect/Clock"
6
7
  import * as Data from "effect/Data"
7
8
  import * as Effect from "effect/Effect"
8
9
  import * as FileSystem from "effect/FileSystem"
@@ -10,7 +11,7 @@ import * as Path from "effect/Path"
10
11
  import type * as PlatformError from "effect/PlatformError"
11
12
  import * as Schema from "effect/Schema"
12
13
  import type { HttpClient } from "effect/unstable/http"
13
- import type { OAuthError } from "../auth/OAuthErrors.js"
14
+ import { OAuthError } from "../auth/OAuthErrors.js"
14
15
  import { refreshToken } from "../auth/OAuthOperations.js"
15
16
  import {
16
17
  type AuthProfile,
@@ -22,7 +23,7 @@ import {
22
23
  } from "./AuthProfiles.js"
23
24
  import { getProfilesPath, type HomeDirectoryError, HomeDirectoryTag } from "./ConfigPaths.js"
24
25
  import { type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
25
- import { FileSystemError, isTokenExpired, loadOAuthConfig } from "./TokenStorage.js"
26
+ import { FileSystemError, isTokenExpiredAt, loadOAuthConfig } from "./TokenStorage.js"
26
27
 
27
28
  export interface AtlassianToolDefinition {
28
29
  readonly toolName: string
@@ -42,6 +43,25 @@ export const JIRA_REQUIRED_SCOPES: ReadonlyArray<string> = [
42
43
  "offline_access"
43
44
  ]
44
45
 
46
+ /** Legacy read-only Jira requirements retained for proposal-only consumers. */
47
+ export const JIRA_PROPOSAL_REQUIRED_SCOPES: ReadonlyArray<string> = [
48
+ "read:jira-work",
49
+ "read:jira-user",
50
+ "read:me",
51
+ "offline_access"
52
+ ]
53
+
54
+ /**
55
+ * Scopes a Confluence profile must hold to be considered valid.
56
+ *
57
+ * Deliberately page-only. The CLI also requests the folder and CQL-search scopes
58
+ * (`CONFLUENCE_FOLDER_SCOPES`), but they are not listed here: this constant also
59
+ * gates control-center's per-site scope check, and a profile that can read and
60
+ * write pages is legitimately valid for everything except `folder`/`search`.
61
+ * The cost is that `atlassian profiles doctor` reports a pre-folder profile as
62
+ * valid and those two commands then fail with 401/403 until the OAuth app is
63
+ * updated and the user logs in again — see the confluence-to-markdown README.
64
+ */
45
65
  export const CONFLUENCE_REQUIRED_SCOPES: ReadonlyArray<string> = [
46
66
  "read:page:confluence",
47
67
  "write:page:confluence",
@@ -135,7 +155,7 @@ const loadLegacyToken = (
135
155
  )
136
156
  const parsed = yield* Effect.try({
137
157
  try: () => JSON.parse(content),
138
- catch: (cause) => cause
158
+ catch: (): "invalid-json" => "invalid-json"
139
159
  }).pipe(Effect.catch(() => Effect.succeed(null)))
140
160
  if (parsed === null) return null
141
161
  return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
@@ -166,12 +186,15 @@ export const inspectToolProfiles = (
166
186
  const [store, config] = yield* Effect.all([loadProfiles(storeName), loadOAuthConfig(storeName)])
167
187
  const activeProfile = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
168
188
  null
189
+ const nowMs = yield* Clock.currentTimeMillis
169
190
  return {
170
191
  tool,
171
192
  authStoreName: storeName,
172
193
  activeProfile,
173
194
  profiles: store.profiles,
174
- tokenStatus: activeProfile === null ? "missing" : isTokenExpired(activeProfile.token, 0) ? "expired" : "valid",
195
+ tokenStatus: activeProfile === null ? "missing" : isTokenExpiredAt(activeProfile.token, nowMs, 0)
196
+ ? "expired"
197
+ : "valid",
175
198
  missingScopes: activeProfile === null ? [] : missingScopes(activeProfile.token, tool.requiredScopes),
176
199
  oauthConfigured: config !== null
177
200
  }
@@ -248,6 +271,9 @@ export const migrateLegacyProfiles = (
248
271
  return yield* inspectAllToolProfiles(tools)
249
272
  })
250
273
 
274
+ /** Matches `JiraAuth`'s refresh deadline; see the rotation comment below. */
275
+ const REFRESH_TIMEOUT = "30 seconds"
276
+
251
277
  /**
252
278
  * Refresh expired active profiles.
253
279
  *
@@ -268,13 +294,36 @@ export const refreshActiveProfiles = (
268
294
  const store = yield* loadProfiles(storeName)
269
295
  const active = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
270
296
  null
271
- if (!active || !isTokenExpired(active.token, 0)) return
297
+ const nowMs = yield* Clock.currentTimeMillis
298
+ if (!active || !isTokenExpiredAt(active.token, nowMs, 0)) return
272
299
  const config = yield* loadOAuthConfig(storeName)
273
300
  if (!config) {
274
301
  return yield* Effect.fail(new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id }))
275
302
  }
276
- const refreshed = yield* refreshToken(active.token, config)
277
- yield* saveProfileToken(storeName, refreshed)
303
+ // Rotating refresh tokens: the grant consumes the stored token
304
+ // server-side and the response carries its replacement, so an interrupt
305
+ // between the two spends the credential without persisting what
306
+ // replaced it — the next refresh 4xxs and the user is silently logged
307
+ // out. Keep the grant and the persist atomic, as `JiraAuth` does.
308
+ // The deadline is inside the region on purpose: an uninterruptible
309
+ // region with no bound of its own absorbs SIGINT/SIGTERM entirely
310
+ // (`runMain`'s handlers only interrupt the main fiber), so `atlassian
311
+ // auth refresh` would stop answering Ctrl-C against a stalled token
312
+ // endpoint. A deadline forked inside the region is still interruptible,
313
+ // so it does bound this. Failing here leaves the stored token in place
314
+ // — a refresh that never answered says nothing about its validity.
315
+ yield* Effect.uninterruptible(
316
+ Effect.gen(function*() {
317
+ const refreshed = yield* refreshToken(active.token, config).pipe(
318
+ Effect.timeout(REFRESH_TIMEOUT),
319
+ Effect.catchTag(
320
+ "TimeoutError",
321
+ () => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
322
+ )
323
+ )
324
+ yield* saveProfileToken(storeName, refreshed)
325
+ })
326
+ )
278
327
  }))
279
328
  return yield* inspectAllToolProfiles(tools)
280
329
  })
@@ -23,13 +23,16 @@ import * as FileSystem from "effect/FileSystem"
23
23
  import type * as Path from "effect/Path"
24
24
  import type * as PlatformError from "effect/PlatformError"
25
25
  import * as Schema from "effect/Schema"
26
+ import { unsafeCurrentTimeMillis } from "../internal/legacyWallClock.js"
26
27
  import { type HomeDirectoryError, type HomeDirectoryTag } from "./ConfigPaths.js"
27
28
  import { ensureConfigDir, getAuthPath, getOAuthConfigPath, writeSecureFile } from "./ConfigPaths.js"
28
29
  import { type OAuthConfig, OAuthConfigSchema, type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
29
30
 
30
- const parseJsonOrNull = (content: string): unknown | null => {
31
+ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
32
+
33
+ const parseJsonOrNull = (content: string): Schema.Json | null => {
31
34
  try {
32
- return JSON.parse(content)
35
+ return decodeJson(content)
33
36
  } catch {
34
37
  return null
35
38
  }
@@ -70,7 +73,7 @@ export const loadToken = (
70
73
  const tokenPath = yield* getAuthPath(toolName)
71
74
 
72
75
  const exists = yield* fs.exists(tokenPath).pipe(
73
- Effect.catchCause(() => Effect.succeed(false))
76
+ Effect.catch(() => Effect.succeed(false))
74
77
  )
75
78
  if (!exists) {
76
79
  return null
@@ -87,7 +90,7 @@ export const loadToken = (
87
90
  }
88
91
 
89
92
  return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
90
- Effect.catchCause((e) =>
93
+ Effect.catch((e) =>
91
94
  Effect.logWarning(`Invalid token schema in ${tokenPath}: ${e}`).pipe(
92
95
  Effect.map(() => null)
93
96
  )
@@ -136,7 +139,7 @@ export const deleteToken = (
136
139
  const tokenPath = yield* getAuthPath(toolName)
137
140
 
138
141
  yield* fs.remove(tokenPath).pipe(
139
- Effect.catchCause(() => Effect.void)
142
+ Effect.catch(() => Effect.void)
140
143
  )
141
144
  })
142
145
 
@@ -160,7 +163,7 @@ export const loadOAuthConfig = (
160
163
  const configPath = yield* getOAuthConfigPath(toolName)
161
164
 
162
165
  const exists = yield* fs.exists(configPath).pipe(
163
- Effect.catchCause(() => Effect.succeed(false))
166
+ Effect.catch(() => Effect.succeed(false))
164
167
  )
165
168
  if (!exists) {
166
169
  return null
@@ -177,7 +180,7 @@ export const loadOAuthConfig = (
177
180
  }
178
181
 
179
182
  return yield* Schema.decodeUnknownEffect(OAuthConfigSchema)(parsed).pipe(
180
- Effect.catchCause((e) =>
183
+ Effect.catch((e) =>
181
184
  Effect.logWarning(`Invalid OAuth config schema in ${configPath}: ${e}`).pipe(
182
185
  Effect.map(() => null)
183
186
  )
@@ -220,5 +223,18 @@ export const saveOAuthConfig = (
220
223
  *
221
224
  * @category Utilities
222
225
  */
226
+ export const isTokenExpiredAt = (
227
+ token: OAuthToken,
228
+ nowMs: number,
229
+ bufferMs: number = 5 * 60 * 1000
230
+ ): boolean => nowMs >= token.expires_at - bufferMs
231
+
232
+ /**
233
+ * Check token expiration using the host clock.
234
+ *
235
+ * @deprecated Effect workflows should call {@link isTokenExpiredAt} with
236
+ * `Clock.currentTimeMillis`. This wrapper preserves the existing synchronous
237
+ * public API until the next major release.
238
+ */
223
239
  export const isTokenExpired = (token: OAuthToken, bufferMs: number = 5 * 60 * 1000): boolean =>
224
- Date.now() >= token.expires_at - bufferMs
240
+ isTokenExpiredAt(token, unsafeCurrentTimeMillis(), bufferMs)
@@ -37,6 +37,7 @@ export {
37
37
  deleteToken,
38
38
  FileSystemError,
39
39
  isTokenExpired,
40
+ isTokenExpiredAt,
40
41
  loadOAuthConfig,
41
42
  loadToken,
42
43
  saveOAuthConfig,
@@ -72,6 +73,7 @@ export {
72
73
  CONFLUENCE_REQUIRED_SCOPES,
73
74
  inspectAllToolProfiles,
74
75
  inspectToolProfiles,
76
+ JIRA_PROPOSAL_REQUIRED_SCOPES,
75
77
  JIRA_REQUIRED_SCOPES,
76
78
  migrateLegacyProfiles,
77
79
  MissingOAuthConfigError,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Compatibility boundary for legacy synchronous OAuth helpers.
3
+ *
4
+ * New Effect workflows must obtain time from `Clock.currentTimeMillis` and call
5
+ * the corresponding `*At` helper. This host-clock read remains only so the
6
+ * existing synchronous public API does not change without a major release.
7
+ *
8
+ * @internal
9
+ */
10
+ export const unsafeCurrentTimeMillis = (): number => Date.now()
@@ -0,0 +1,80 @@
1
+ import * as NodeServices from "@effect/platform-node/NodeServices"
2
+ import { describe, expect, it } from "@effect/vitest"
3
+ import * as Effect from "effect/Effect"
4
+ import * as FileSystem from "effect/FileSystem"
5
+ import * as Path from "effect/Path"
6
+ import * as Predicate from "effect/Predicate"
7
+ import type * as Schema from "effect/Schema"
8
+ import { parse } from "yaml"
9
+
10
+ const isRecord = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & Record<string, Schema.Json> =>
11
+ Predicate.isObjectOrArray(value) && value !== null && !Array.isArray(value)
12
+
13
+ const buildCommands = (source: string): ReadonlyArray<string> => {
14
+ const workflow: unknown = parse(source)
15
+ if (!isRecord(workflow) || !isRecord(workflow.jobs)) return []
16
+
17
+ return Object.values(workflow.jobs).flatMap((job) => {
18
+ if (!isRecord(job) || !Array.isArray(job.steps)) return []
19
+ return job.steps.flatMap((step) => {
20
+ if (!isRecord(step) || !Predicate.isString(step.name) || !step.name.startsWith("Build ")) return []
21
+ return Predicate.isString(step.run) ? [step.run] : []
22
+ })
23
+ })
24
+ }
25
+
26
+ const dependencyClosureDiagnostics = (
27
+ source: string,
28
+ expectedConsumers: ReadonlyArray<string>
29
+ ): ReadonlyArray<string> => {
30
+ const commands = buildCommands(source)
31
+ return expectedConsumers.flatMap((consumer) => {
32
+ const dependencyClosedFilter = `--filter ${consumer}...`
33
+ return commands.some((command) => command.includes(dependencyClosedFilter) && /\bbuild\b/u.test(command))
34
+ ? []
35
+ : [`API update workflow must build ${consumer} with its workspace dependency closure`]
36
+ })
37
+ }
38
+
39
+ const loadWorkflow = (name: string) =>
40
+ Effect.gen(function*() {
41
+ const fileSystem = yield* FileSystem.FileSystem
42
+ const path = yield* Path.Path
43
+ const workflowPath = yield* path.fromFileUrl(new URL(`../../../.github/workflows/${name}`, import.meta.url))
44
+ return yield* fileSystem.readFileString(workflowPath)
45
+ }).pipe(Effect.provide(NodeServices.layer))
46
+
47
+ describe("API update workflow build closure", () => {
48
+ it("rejects a bare consumer build and accepts a dependency-closed build", () => {
49
+ const invalid = `
50
+ jobs:
51
+ update:
52
+ steps:
53
+ - name: Build generated client and consumer
54
+ run: pnpm --filter @knpkv/confluence-to-markdown build
55
+ `
56
+ const valid = `
57
+ jobs:
58
+ update:
59
+ steps:
60
+ - name: Build generated client and consumer
61
+ run: pnpm --filter @knpkv/confluence-to-markdown... build
62
+ `
63
+
64
+ expect(dependencyClosureDiagnostics(invalid, ["@knpkv/confluence-to-markdown"])).toEqual([
65
+ "API update workflow must build @knpkv/confluence-to-markdown with its workspace dependency closure"
66
+ ])
67
+ expect(dependencyClosureDiagnostics(valid, ["@knpkv/confluence-to-markdown"])).toEqual([])
68
+ })
69
+
70
+ it.effect("builds every API consumer with its workspace dependencies", () =>
71
+ Effect.gen(function*() {
72
+ const confluence = yield* loadWorkflow("confluence-api-update.yml")
73
+ const jira = yield* loadWorkflow("jira-api-update.yml")
74
+ const clockify = yield* loadWorkflow("clockify-api-update.yml")
75
+
76
+ expect(dependencyClosureDiagnostics(confluence, ["@knpkv/confluence-to-markdown"])).toEqual([])
77
+ expect(dependencyClosureDiagnostics(jira, ["@knpkv/jira-cli", "@knpkv/jira-clockify"])).toEqual([])
78
+ expect(dependencyClosureDiagnostics(clockify, ["@knpkv/jira-clockify"])).toEqual([])
79
+ }))
80
+ })
@@ -6,6 +6,7 @@ import {
6
6
  AUTH_URL,
7
7
  buildAuthUrl,
8
8
  computeCodeChallenge,
9
+ CONFLUENCE_FOLDER_SCOPES,
9
10
  CONFLUENCE_SCOPES,
10
11
  generateCodeVerifier
11
12
  } from "../src/auth/OAuthEndpoints.js"
@@ -18,6 +19,28 @@ describe("OAuthEndpoints", () => {
18
19
  expect(CONFLUENCE_SCOPES).toContain("read:attachment:confluence")
19
20
  expect(CONFLUENCE_SCOPES).toContain("write:attachment:confluence")
20
21
  })
22
+
23
+ // Control Center feeds this constant straight into its own authorize
24
+ // request, so folder authority must not leak in here — it would widen every
25
+ // Control Center sign-in to a scope its documented OAuth app never enables.
26
+ it("carries no folder or search authority", () => {
27
+ for (const scope of CONFLUENCE_FOLDER_SCOPES) {
28
+ expect(CONFLUENCE_SCOPES).not.toContain(scope)
29
+ }
30
+ })
31
+ })
32
+
33
+ describe("CONFLUENCE_FOLDER_SCOPES", () => {
34
+ // Exact strings: Atlassian rejects the whole authorize request for one
35
+ // unrecognized scope, and a typo surfaces only on a real consent screen.
36
+ it("names the scopes the folder and CQL-search endpoints require", () => {
37
+ expect([...CONFLUENCE_FOLDER_SCOPES].sort()).toEqual([
38
+ "read:content-details:confluence",
39
+ "read:folder:confluence",
40
+ "read:hierarchical-content:confluence",
41
+ "write:folder:confluence"
42
+ ])
43
+ })
21
44
  })
22
45
 
23
46
  // ---- PKCE ----
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import { ConfigProvider, Effect, Layer } from "effect"
3
+ import * as Clock from "effect/Clock"
4
+ import * as Deferred from "effect/Deferred"
5
+ import * as Fiber from "effect/Fiber"
3
6
  import * as FileSystem from "effect/FileSystem"
4
7
  import * as Path from "effect/Path"
5
8
  import { SystemError } from "effect/PlatformError"
9
+ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
6
10
  import {
7
11
  type AtlassianToolDefinition,
8
12
  HomeDirectoryLive,
@@ -13,6 +17,7 @@ import {
13
17
  type OAuthToken,
14
18
  ProfileNotFoundError,
15
19
  refreshActiveProfiles,
20
+ saveOAuthConfig,
16
21
  saveProfileToken,
17
22
  useProfileForAllTools
18
23
  } from "../src/config/index.js"
@@ -175,6 +180,62 @@ describe("ProfileManager", () => {
175
180
  ).rejects.toBeInstanceOf(ProfileNotFoundError)
176
181
  })
177
182
 
183
+ // Atlassian rotates refresh tokens, so the grant consumes the stored one and
184
+ // the response carries its replacement. If an interrupt can land between the
185
+ // two, the credential is spent with nothing saved and the profile is silently
186
+ // logged out — hence the uninterruptible region around grant-and-persist.
187
+ it("persists the rotated token even when interrupted mid-rotation", async () => {
188
+ const mock = makeMockFS()
189
+ const program = Effect.gen(function*() {
190
+ yield* saveOAuthConfig("tool-a", { clientId: "client-1", clientSecret: "secret-1" })
191
+ const nowMs = yield* Clock.currentTimeMillis
192
+ yield* saveProfileToken("tool-a", makeToken(1, "read:me offline_access", nowMs - 1_000))
193
+
194
+ const issued = yield* Deferred.make<void>()
195
+ const release = yield* Deferred.make<void>()
196
+ const client = Layer.succeed(
197
+ HttpClient.HttpClient,
198
+ HttpClient.make((request) =>
199
+ Effect.gen(function*() {
200
+ yield* Deferred.succeed(issued, undefined)
201
+ yield* Deferred.await(release)
202
+ return HttpClientResponse.fromWeb(
203
+ request,
204
+ new Response(
205
+ JSON.stringify({
206
+ access_token: "fresh-access",
207
+ refresh_token: "rotated-refresh",
208
+ expires_in: 3600,
209
+ scope: "read:me offline_access",
210
+ token_type: "Bearer"
211
+ }),
212
+ { status: 200, headers: { "content-type": "application/json" } }
213
+ )
214
+ )
215
+ })
216
+ )
217
+ )
218
+
219
+ const fiber = yield* refreshActiveProfiles([tools[0]!]).pipe(
220
+ Effect.provide(client),
221
+ Effect.exit,
222
+ Effect.forkChild({ startImmediately: true })
223
+ )
224
+ yield* Deferred.await(issued)
225
+ const interrupting = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild({ startImmediately: true }))
226
+ yield* Deferred.succeed(release, undefined)
227
+ yield* Fiber.join(interrupting)
228
+ })
229
+
230
+ await Effect.runPromise(
231
+ program.pipe(
232
+ Effect.provide(Layer.mergeAll(mock.layer, Path.layer, HomeDirectoryLive, ConfigProviderLive))
233
+ )
234
+ )
235
+
236
+ expect(JSON.stringify(mock.store)).toContain("rotated-refresh")
237
+ })
238
+
178
239
  it("fails expired-token refresh when OAuth config is missing", async () => {
179
240
  await expect(
180
241
  run(
@@ -0,0 +1,16 @@
1
+ import { JIRA_PROPOSAL_SCOPES, JIRA_SCOPES } from "../src/auth/index.js"
2
+ import { JIRA_PROPOSAL_REQUIRED_SCOPES, JIRA_REQUIRED_SCOPES } from "../src/config/index.js"
3
+
4
+ describe("public compatibility exports", () => {
5
+ it("retains proposal-only Jira scopes beside the write-capable release scopes", () => {
6
+ expect(JIRA_PROPOSAL_SCOPES).toEqual([
7
+ "read:jira-work",
8
+ "read:jira-user",
9
+ "read:me",
10
+ "offline_access"
11
+ ])
12
+ expect(JIRA_PROPOSAL_REQUIRED_SCOPES).toEqual(JIRA_PROPOSAL_SCOPES)
13
+ expect(JIRA_SCOPES).toContain("manage:jira-configuration")
14
+ expect(JIRA_REQUIRED_SCOPES).toContain("manage:jira-project")
15
+ })
16
+ })