@knpkv/atlassian-common 1.1.0 → 1.3.0

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 (51) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +6 -0
  3. package/dist/auth/OAuthEndpoints.d.ts +12 -0
  4. package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
  5. package/dist/auth/OAuthEndpoints.js +16 -1
  6. package/dist/auth/OAuthEndpoints.js.map +1 -1
  7. package/dist/auth/OAuthOperations.d.ts +10 -0
  8. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  9. package/dist/auth/OAuthOperations.js +12 -3
  10. package/dist/auth/OAuthOperations.js.map +1 -1
  11. package/dist/auth/index.d.ts +2 -2
  12. package/dist/auth/index.d.ts.map +1 -1
  13. package/dist/auth/index.js +5 -3
  14. package/dist/auth/index.js.map +1 -1
  15. package/dist/bin.js +2 -2
  16. package/dist/bin.js.map +1 -1
  17. package/dist/config/ProfileManager.d.ts +2 -0
  18. package/dist/config/ProfileManager.d.ts.map +1 -1
  19. package/dist/config/ProfileManager.js +16 -4
  20. package/dist/config/ProfileManager.js.map +1 -1
  21. package/dist/config/ProfileStorage.d.ts +5 -0
  22. package/dist/config/ProfileStorage.d.ts.map +1 -0
  23. package/dist/config/ProfileStorage.js +5 -0
  24. package/dist/config/ProfileStorage.js.map +1 -0
  25. package/dist/config/TokenStorage.d.ts +8 -0
  26. package/dist/config/TokenStorage.d.ts.map +1 -1
  27. package/dist/config/TokenStorage.js +15 -6
  28. package/dist/config/TokenStorage.js.map +1 -1
  29. package/dist/config/index.d.ts +2 -2
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/index.js +2 -2
  32. package/dist/config/index.js.map +1 -1
  33. package/dist/internal/legacyWallClock.d.ts +11 -0
  34. package/dist/internal/legacyWallClock.d.ts.map +1 -0
  35. package/dist/internal/legacyWallClock.js +11 -0
  36. package/dist/internal/legacyWallClock.js.map +1 -0
  37. package/package.json +11 -7
  38. package/src/auth/OAuthEndpoints.ts +19 -1
  39. package/src/auth/OAuthOperations.ts +21 -4
  40. package/src/auth/index.ts +5 -1
  41. package/src/bin.ts +1 -4
  42. package/src/config/ProfileManager.ts +17 -4
  43. package/src/config/ProfileStorage.ts +5 -0
  44. package/src/config/TokenStorage.ts +20 -6
  45. package/src/config/index.ts +2 -0
  46. package/src/internal/legacyWallClock.ts +10 -0
  47. package/test/OAuthEndpoints.test.ts +14 -0
  48. package/test/OAuthOperations.test.ts +16 -0
  49. package/test/PublicExports.test.ts +16 -0
  50. package/tsconfig.build.json +9 -0
  51. package/tsconfig.tsbuildinfo +0 -1
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export { ensureConfigDir, getAuthPath, getConfigDir, getOAuthConfigPath, getProfilesPath, type HomeDirectory, HomeDirectoryError, HomeDirectoryLive, HomeDirectoryTag, writeSecureFile } from "./ConfigPaths.js";
7
7
  export { type AtlassianSite, AtlassianSiteSchema, type AtlassianUser, AtlassianUserSchema, type OAuthConfig, OAuthConfigSchema, type OAuthToken, OAuthTokenSchema, type OAuthUser, OAuthUserSchema } from "./OAuthSchemas.js";
8
- export { deleteToken, FileSystemError, isTokenExpired, loadOAuthConfig, loadToken, saveOAuthConfig, saveToken } from "./TokenStorage.js";
8
+ export { deleteToken, FileSystemError, isTokenExpired, isTokenExpiredAt, loadOAuthConfig, loadToken, saveOAuthConfig, saveToken } from "./TokenStorage.js";
9
9
  export { type AuthProfile, AuthProfileSchema, type AuthProfilesFile, AuthProfilesFileSchema, deleteActiveProfile, deleteProfile, deleteProfileBySelector, findProfile, loadActiveProfile, loadActiveProfileToken, loadProfiles, profileFromToken, profileIdFromToken, profileNameFromToken, saveProfiles, saveProfileToken, setActiveProfile, setActiveProfileBySelector } from "./AuthProfiles.js";
10
- export { ATLASSIAN_TOOLS, type AtlassianToolDefinition, CONFLUENCE_REQUIRED_SCOPES, inspectAllToolProfiles, inspectToolProfiles, JIRA_REQUIRED_SCOPES, migrateLegacyProfiles, MissingOAuthConfigError, missingScopes, ProfileNotFoundError, type ProfileTokenStatus, refreshActiveProfiles, tokenScopes, type ToolProfileStatus, useProfileForAllTools } from "./ProfileManager.js";
10
+ export { ATLASSIAN_TOOLS, type AtlassianToolDefinition, CONFLUENCE_REQUIRED_SCOPES, inspectAllToolProfiles, inspectToolProfiles, JIRA_PROPOSAL_REQUIRED_SCOPES, JIRA_REQUIRED_SCOPES, migrateLegacyProfiles, MissingOAuthConfigError, missingScopes, ProfileNotFoundError, type ProfileTokenStatus, refreshActiveProfiles, tokenScopes, type ToolProfileStatus, useProfileForAllTools } from "./ProfileManager.js";
11
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,eAAe,EACf,KAAK,aAAa,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,EAChB,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,KAAK,aAAa,EAClB,mBAAmB,EACnB,KAAK,aAAa,EAClB,mBAAmB,EACnB,KAAK,WAAW,EAChB,iBAAiB,EACjB,KAAK,UAAU,EACf,gBAAgB,EAChB,KAAK,SAAS,EACd,eAAe,EAChB,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EACL,WAAW,EACX,eAAe,EACf,cAAc,EACd,eAAe,EACf,SAAS,EACT,eAAe,EACf,SAAS,EACV,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EACL,KAAK,WAAW,EAChB,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,uBAAuB,EACvB,WAAW,EACX,iBAAiB,EACjB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,0BAA0B,EAC3B,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EACL,eAAe,EACf,KAAK,uBAAuB,EAC5B,0BAA0B,EAC1B,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,aAAa,EACb,oBAAoB,EACpB,KAAK,kBAAkB,EACvB,qBAAqB,EACrB,WAAW,EACX,KAAK,iBAAiB,EACtB,qBAAqB,EACtB,MAAM,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,eAAe,EACf,KAAK,aAAa,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,EAChB,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,KAAK,aAAa,EAClB,mBAAmB,EACnB,KAAK,aAAa,EAClB,mBAAmB,EACnB,KAAK,WAAW,EAChB,iBAAiB,EACjB,KAAK,UAAU,EACf,gBAAgB,EAChB,KAAK,SAAS,EACd,eAAe,EAChB,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EACL,WAAW,EACX,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,SAAS,EACT,eAAe,EACf,SAAS,EACV,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EACL,KAAK,WAAW,EAChB,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,uBAAuB,EACvB,WAAW,EACX,iBAAiB,EACjB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,0BAA0B,EAC3B,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EACL,eAAe,EACf,KAAK,uBAAuB,EAC5B,0BAA0B,EAC1B,sBAAsB,EACtB,mBAAmB,EACnB,6BAA6B,EAC7B,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,aAAa,EACb,oBAAoB,EACpB,KAAK,kBAAkB,EACvB,qBAAqB,EACrB,WAAW,EACX,KAAK,iBAAiB,EACtB,qBAAqB,EACtB,MAAM,qBAAqB,CAAA"}
@@ -8,9 +8,9 @@ export { ensureConfigDir, getAuthPath, getConfigDir, getOAuthConfigPath, getProf
8
8
  // OAuth schemas
9
9
  export { AtlassianSiteSchema, AtlassianUserSchema, OAuthConfigSchema, OAuthTokenSchema, OAuthUserSchema } from "./OAuthSchemas.js";
10
10
  // Token storage
11
- export { deleteToken, FileSystemError, isTokenExpired, loadOAuthConfig, loadToken, saveOAuthConfig, saveToken } from "./TokenStorage.js";
11
+ export { deleteToken, FileSystemError, isTokenExpired, isTokenExpiredAt, loadOAuthConfig, loadToken, saveOAuthConfig, saveToken } from "./TokenStorage.js";
12
12
  // Auth profiles
13
13
  export { AuthProfileSchema, AuthProfilesFileSchema, deleteActiveProfile, deleteProfile, deleteProfileBySelector, findProfile, loadActiveProfile, loadActiveProfileToken, loadProfiles, profileFromToken, profileIdFromToken, profileNameFromToken, saveProfiles, saveProfileToken, setActiveProfile, setActiveProfileBySelector } from "./AuthProfiles.js";
14
14
  // Unified profile manager
15
- export { ATLASSIAN_TOOLS, CONFLUENCE_REQUIRED_SCOPES, inspectAllToolProfiles, inspectToolProfiles, JIRA_REQUIRED_SCOPES, migrateLegacyProfiles, MissingOAuthConfigError, missingScopes, ProfileNotFoundError, refreshActiveProfiles, tokenScopes, useProfileForAllTools } from "./ProfileManager.js";
15
+ export { ATLASSIAN_TOOLS, CONFLUENCE_REQUIRED_SCOPES, inspectAllToolProfiles, inspectToolProfiles, JIRA_PROPOSAL_REQUIRED_SCOPES, JIRA_REQUIRED_SCOPES, migrateLegacyProfiles, MissingOAuthConfigError, missingScopes, ProfileNotFoundError, refreshActiveProfiles, tokenScopes, useProfileForAllTools } from "./ProfileManager.js";
16
16
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,eAAe;AACf,OAAO,EACL,eAAe,EACf,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,eAAe,EAEf,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,EAChB,MAAM,kBAAkB,CAAA;AAEzB,gBAAgB;AAChB,OAAO,EAEL,mBAAmB,EAEnB,mBAAmB,EAEnB,iBAAiB,EAEjB,gBAAgB,EAEhB,eAAe,EAChB,MAAM,mBAAmB,CAAA;AAE1B,gBAAgB;AAChB,OAAO,EACL,WAAW,EACX,eAAe,EACf,cAAc,EACd,eAAe,EACf,SAAS,EACT,eAAe,EACf,SAAS,EACV,MAAM,mBAAmB,CAAA;AAE1B,gBAAgB;AAChB,OAAO,EAEL,iBAAiB,EAEjB,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,uBAAuB,EACvB,WAAW,EACX,iBAAiB,EACjB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,0BAA0B,EAC3B,MAAM,mBAAmB,CAAA;AAE1B,0BAA0B;AAC1B,OAAO,EACL,eAAe,EAEf,0BAA0B,EAC1B,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,aAAa,EACb,oBAAoB,EAEpB,qBAAqB,EACrB,WAAW,EAEX,qBAAqB,EACtB,MAAM,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,eAAe;AACf,OAAO,EACL,eAAe,EACf,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,eAAe,EAEf,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,EAChB,MAAM,kBAAkB,CAAA;AAEzB,gBAAgB;AAChB,OAAO,EAEL,mBAAmB,EAEnB,mBAAmB,EAEnB,iBAAiB,EAEjB,gBAAgB,EAEhB,eAAe,EAChB,MAAM,mBAAmB,CAAA;AAE1B,gBAAgB;AAChB,OAAO,EACL,WAAW,EACX,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,SAAS,EACT,eAAe,EACf,SAAS,EACV,MAAM,mBAAmB,CAAA;AAE1B,gBAAgB;AAChB,OAAO,EAEL,iBAAiB,EAEjB,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,uBAAuB,EACvB,WAAW,EACX,iBAAiB,EACjB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,0BAA0B,EAC3B,MAAM,mBAAmB,CAAA;AAE1B,0BAA0B;AAC1B,OAAO,EACL,eAAe,EAEf,0BAA0B,EAC1B,sBAAsB,EACtB,mBAAmB,EACnB,6BAA6B,EAC7B,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,aAAa,EACb,oBAAoB,EAEpB,qBAAqB,EACrB,WAAW,EAEX,qBAAqB,EACtB,MAAM,qBAAqB,CAAA"}
@@ -0,0 +1,11 @@
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 declare const unsafeCurrentTimeMillis: () => number;
11
+ //# sourceMappingURL=legacyWallClock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"legacyWallClock.d.ts","sourceRoot":"","sources":["../../src/internal/legacyWallClock.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,QAAO,MAAoB,CAAA"}
@@ -0,0 +1,11 @@
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 = () => Date.now();
11
+ //# sourceMappingURL=legacyWallClock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"legacyWallClock.js","sourceRoot":"","sources":["../../src/internal/legacyWallClock.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAW,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/atlassian-common",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Shared utilities for Atlassian tools (AST, serializers, auth, config)",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -49,28 +49,32 @@
49
49
  "./profile-manager": {
50
50
  "import": "./dist/config/ProfileManager.js",
51
51
  "types": "./dist/config/ProfileManager.d.ts"
52
+ },
53
+ "./profile-storage": {
54
+ "import": "./dist/config/ProfileStorage.js",
55
+ "types": "./dist/config/ProfileStorage.d.ts"
52
56
  }
53
57
  },
54
58
  "publishConfig": {
55
59
  "access": "public"
56
60
  },
57
61
  "peerDependencies": {
58
- "effect": "4.0.0-beta.93"
62
+ "effect": "4.0.0-beta.98"
59
63
  },
60
64
  "dependencies": {
61
- "@effect/platform-node": "4.0.0-beta.93",
65
+ "@effect/platform-node": "4.0.0-beta.98",
62
66
  "remark-gfm": "^4.0.1",
63
67
  "remark-parse": "^11.0.0",
64
68
  "remark-stringify": "^11.0.0",
65
69
  "unified": "^11.0.5"
66
70
  },
67
71
  "devDependencies": {
68
- "@effect/platform-node": "4.0.0-beta.93",
69
- "@effect/vitest": "4.0.0-beta.93",
72
+ "@effect/platform-node": "4.0.0-beta.98",
73
+ "@effect/vitest": "4.0.0-beta.98",
70
74
  "@types/node": "latest",
71
- "effect": "4.0.0-beta.93",
75
+ "effect": "4.0.0-beta.98",
72
76
  "typescript": "~6.0.3",
73
- "vitest": "^4.1.9"
77
+ "vitest": "^4.1.10"
74
78
  },
75
79
  "keywords": [
76
80
  "effect",
@@ -82,6 +82,22 @@ export const JIRA_SCOPES = [
82
82
  "offline_access"
83
83
  ]
84
84
 
85
+ /**
86
+ * Legacy proposal-only Jira scopes retained for public API compatibility.
87
+ *
88
+ * Control Center owns a separate least-privilege release-publication scope set;
89
+ * {@link JIRA_SCOPES} remains the legacy write-capable set for other consumers,
90
+ * while proposal-only integrations may continue using this read-only set.
91
+ *
92
+ * @category Scopes
93
+ */
94
+ export const JIRA_PROPOSAL_SCOPES = [
95
+ "read:jira-work",
96
+ "read:jira-user",
97
+ "read:me",
98
+ "offline_access"
99
+ ]
100
+
85
101
  /**
86
102
  * Options for building OAuth2 authorization URL.
87
103
  *
@@ -94,6 +110,8 @@ export interface BuildAuthUrlOptions {
94
110
  readonly state: string
95
111
  /** Local callback server port */
96
112
  readonly port: number
113
+ /** Exact callback URL for an existing application server. */
114
+ readonly redirectUri?: string | undefined
97
115
  /** OAuth2 scopes */
98
116
  readonly scopes: ReadonlyArray<string>
99
117
  /** PKCE code_challenge (S256). Omit to skip PKCE. */
@@ -110,7 +128,7 @@ export const buildAuthUrl = (options: BuildAuthUrlOptions): string => {
110
128
  audience: "api.atlassian.com",
111
129
  client_id: options.clientId,
112
130
  scope: options.scopes.join(" "),
113
- redirect_uri: `http://localhost:${options.port}/callback`,
131
+ redirect_uri: options.redirectUri ?? `http://localhost:${options.port}/callback`,
114
132
  state: options.state,
115
133
  response_type: "code",
116
134
  prompt: "consent"
@@ -23,6 +23,7 @@ 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 {
@@ -42,6 +43,8 @@ import {
42
43
  export interface ExchangeCodeOptions {
43
44
  /** Local callback server port (for redirect_uri) */
44
45
  readonly port: number
46
+ /** Exact callback URL used by the authorization request. */
47
+ readonly redirectUri?: string | undefined
45
48
  /** PKCE code verifier (if PKCE was used in auth request) */
46
49
  readonly codeVerifier?: string | undefined
47
50
  }
@@ -63,7 +66,7 @@ export const exchangeCodeForTokens = (
63
66
  client_id: config.clientId,
64
67
  client_secret: config.clientSecret,
65
68
  code,
66
- redirect_uri: `http://localhost:${options.port}/callback`
69
+ redirect_uri: options.redirectUri ?? `http://localhost:${options.port}/callback`
67
70
  }
68
71
  if (options.codeVerifier) tokenBody.code_verifier = options.codeVerifier
69
72
  const request = yield* HttpClientRequest.post(TOKEN_URL).pipe(
@@ -258,14 +261,15 @@ export const revokeToken = (
258
261
  *
259
262
  * @category Utilities
260
263
  */
261
- export const buildOAuthToken = (
264
+ export const buildOAuthTokenAt = (
262
265
  tokenResponse: TokenResponse,
263
266
  site: AccessibleResource,
264
- user: UserInfo
267
+ user: UserInfo,
268
+ nowMs: number
265
269
  ): OAuthToken => ({
266
270
  access_token: tokenResponse.access_token,
267
271
  refresh_token: tokenResponse.refresh_token,
268
- expires_at: Date.now() + tokenResponse.expires_in * 1000,
272
+ expires_at: nowMs + tokenResponse.expires_in * 1000,
269
273
  scope: tokenResponse.scope,
270
274
  cloud_id: site.id,
271
275
  site_url: site.url,
@@ -275,3 +279,16 @@ export const buildOAuthToken = (
275
279
  email: user.email
276
280
  }
277
281
  })
282
+
283
+ /**
284
+ * Build an OAuth token using the host clock.
285
+ *
286
+ * @deprecated Effect workflows should call {@link buildOAuthTokenAt} with
287
+ * `Clock.currentTimeMillis`. This wrapper preserves the existing synchronous
288
+ * public API until the next major release.
289
+ */
290
+ export const buildOAuthToken = (
291
+ tokenResponse: TokenResponse,
292
+ site: AccessibleResource,
293
+ user: UserInfo
294
+ ): OAuthToken => buildOAuthTokenAt(tokenResponse, site, user, unsafeCurrentTimeMillis())
package/src/auth/index.ts CHANGED
@@ -4,7 +4,9 @@
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,
@@ -12,6 +14,7 @@ export {
12
14
  computeCodeChallenge,
13
15
  CONFLUENCE_SCOPES,
14
16
  generateCodeVerifier,
17
+ JIRA_PROPOSAL_SCOPES,
15
18
  JIRA_SCOPES,
16
19
  ME_URL,
17
20
  RESOURCES_URL,
@@ -25,6 +28,7 @@ export { AuthMissingError, OAuthError, OAuthNotConfiguredError, type OAuthStep }
25
28
  // Operations
26
29
  export {
27
30
  buildOAuthToken,
31
+ buildOAuthTokenAt,
28
32
  exchangeCodeForTokens,
29
33
  type ExchangeCodeOptions,
30
34
  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,12 +99,9 @@ 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
  )
@@ -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"
@@ -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,14 @@ 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
+
45
54
  export const CONFLUENCE_REQUIRED_SCOPES: ReadonlyArray<string> = [
46
55
  "read:page:confluence",
47
56
  "write:page:confluence",
@@ -135,7 +144,7 @@ const loadLegacyToken = (
135
144
  )
136
145
  const parsed = yield* Effect.try({
137
146
  try: () => JSON.parse(content),
138
- catch: (cause) => cause
147
+ catch: (): "invalid-json" => "invalid-json"
139
148
  }).pipe(Effect.catch(() => Effect.succeed(null)))
140
149
  if (parsed === null) return null
141
150
  return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
@@ -166,12 +175,15 @@ export const inspectToolProfiles = (
166
175
  const [store, config] = yield* Effect.all([loadProfiles(storeName), loadOAuthConfig(storeName)])
167
176
  const activeProfile = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
168
177
  null
178
+ const nowMs = yield* Clock.currentTimeMillis
169
179
  return {
170
180
  tool,
171
181
  authStoreName: storeName,
172
182
  activeProfile,
173
183
  profiles: store.profiles,
174
- tokenStatus: activeProfile === null ? "missing" : isTokenExpired(activeProfile.token, 0) ? "expired" : "valid",
184
+ tokenStatus: activeProfile === null ? "missing" : isTokenExpiredAt(activeProfile.token, nowMs, 0)
185
+ ? "expired"
186
+ : "valid",
175
187
  missingScopes: activeProfile === null ? [] : missingScopes(activeProfile.token, tool.requiredScopes),
176
188
  oauthConfigured: config !== null
177
189
  }
@@ -268,7 +280,8 @@ export const refreshActiveProfiles = (
268
280
  const store = yield* loadProfiles(storeName)
269
281
  const active = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
270
282
  null
271
- if (!active || !isTokenExpired(active.token, 0)) return
283
+ const nowMs = yield* Clock.currentTimeMillis
284
+ if (!active || !isTokenExpiredAt(active.token, nowMs, 0)) return
272
285
  const config = yield* loadOAuthConfig(storeName)
273
286
  if (!config) {
274
287
  return yield* Effect.fail(new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id }))
@@ -0,0 +1,5 @@
1
+ /** Filesystem-only Atlassian profile storage used by local service integrations. @module */
2
+
3
+ export { type AuthProfile, loadProfiles } from "./AuthProfiles.js"
4
+ export { HomeDirectoryError, HomeDirectoryLive, HomeDirectoryTag } from "./ConfigPaths.js"
5
+ export { FileSystemError, isTokenExpired } from "./TokenStorage.js"
@@ -23,6 +23,7 @@ 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"
@@ -70,7 +71,7 @@ export const loadToken = (
70
71
  const tokenPath = yield* getAuthPath(toolName)
71
72
 
72
73
  const exists = yield* fs.exists(tokenPath).pipe(
73
- Effect.catchCause(() => Effect.succeed(false))
74
+ Effect.catch(() => Effect.succeed(false))
74
75
  )
75
76
  if (!exists) {
76
77
  return null
@@ -87,7 +88,7 @@ export const loadToken = (
87
88
  }
88
89
 
89
90
  return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
90
- Effect.catchCause((e) =>
91
+ Effect.catch((e) =>
91
92
  Effect.logWarning(`Invalid token schema in ${tokenPath}: ${e}`).pipe(
92
93
  Effect.map(() => null)
93
94
  )
@@ -136,7 +137,7 @@ export const deleteToken = (
136
137
  const tokenPath = yield* getAuthPath(toolName)
137
138
 
138
139
  yield* fs.remove(tokenPath).pipe(
139
- Effect.catchCause(() => Effect.void)
140
+ Effect.catch(() => Effect.void)
140
141
  )
141
142
  })
142
143
 
@@ -160,7 +161,7 @@ export const loadOAuthConfig = (
160
161
  const configPath = yield* getOAuthConfigPath(toolName)
161
162
 
162
163
  const exists = yield* fs.exists(configPath).pipe(
163
- Effect.catchCause(() => Effect.succeed(false))
164
+ Effect.catch(() => Effect.succeed(false))
164
165
  )
165
166
  if (!exists) {
166
167
  return null
@@ -177,7 +178,7 @@ export const loadOAuthConfig = (
177
178
  }
178
179
 
179
180
  return yield* Schema.decodeUnknownEffect(OAuthConfigSchema)(parsed).pipe(
180
- Effect.catchCause((e) =>
181
+ Effect.catch((e) =>
181
182
  Effect.logWarning(`Invalid OAuth config schema in ${configPath}: ${e}`).pipe(
182
183
  Effect.map(() => null)
183
184
  )
@@ -220,5 +221,18 @@ export const saveOAuthConfig = (
220
221
  *
221
222
  * @category Utilities
222
223
  */
224
+ export const isTokenExpiredAt = (
225
+ token: OAuthToken,
226
+ nowMs: number,
227
+ bufferMs: number = 5 * 60 * 1000
228
+ ): boolean => nowMs >= token.expires_at - bufferMs
229
+
230
+ /**
231
+ * Check token expiration using the host clock.
232
+ *
233
+ * @deprecated Effect workflows should call {@link isTokenExpiredAt} with
234
+ * `Clock.currentTimeMillis`. This wrapper preserves the existing synchronous
235
+ * public API until the next major release.
236
+ */
223
237
  export const isTokenExpired = (token: OAuthToken, bufferMs: number = 5 * 60 * 1000): boolean =>
224
- Date.now() >= token.expires_at - bufferMs
238
+ 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()
@@ -90,6 +90,20 @@ describe("OAuthEndpoints", () => {
90
90
  expect(parsed.searchParams.get("prompt")).toBe("consent")
91
91
  })
92
92
 
93
+ it("uses an application callback URL when supplied", () => {
94
+ const url = buildAuthUrl({
95
+ clientId: "cid",
96
+ state: "state-123",
97
+ port: 4173,
98
+ redirectUri: "http://127.0.0.1:4173/services/oauth/atlassian/callback",
99
+ scopes: ["read:me"]
100
+ })
101
+
102
+ expect(new URL(url).searchParams.get("redirect_uri")).toBe(
103
+ "http://127.0.0.1:4173/services/oauth/atlassian/callback"
104
+ )
105
+ })
106
+
93
107
  // PKCE is optional — omitting codeChallenge must not add params (backwards compat)
94
108
  it("omits PKCE params when codeChallenge not provided", () => {
95
109
  const url = buildAuthUrl({
@@ -127,6 +127,22 @@ describe("OAuthOperations", () => {
127
127
  expect(body.code_verifier).toBe("my-verifier")
128
128
  }))
129
129
 
130
+ it.effect("uses the same application callback URL during exchange", () =>
131
+ Effect.gen(function*() {
132
+ const { capturedRequests, mockClient } = createMockHttpClient([{ status: 200, body: validTokenBody }])
133
+ const redirectUri = "http://127.0.0.1:4173/services/oauth/atlassian/callback"
134
+
135
+ yield* exchangeCodeForTokens("auth-code", oauthConfig, { port: 4173, redirectUri }).pipe(
136
+ provide(mockClient)
137
+ )
138
+
139
+ const request = capturedRequests[0]!
140
+ const body = yield* request.body._tag === "Uint8Array"
141
+ ? Effect.succeed(JSON.parse(new TextDecoder().decode(request.body.body)))
142
+ : Effect.succeed({})
143
+ expect(body.redirect_uri).toBe(redirectUri)
144
+ }))
145
+
130
146
  // Token exchange failures (invalid code, expired code) must surface as OAuthError with step="token"
131
147
  it.effect("returns OAuthError with step=token on HTTP 400", () =>
132
148
  Effect.gen(function*() {
@@ -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
+ })
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "http://json.schemastore.org/tsconfig",
3
+ "extends": "./tsconfig.json",
4
+ "compilerOptions": {
5
+ "composite": true,
6
+ "incremental": true,
7
+ "tsBuildInfoFile": "node_modules/.cache/tsconfig.build.tsbuildinfo"
8
+ }
9
+ }