@knpkv/atlassian-common 0.2.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 (121) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/Brand.d.ts +118 -0
  5. package/dist/Brand.d.ts.map +1 -0
  6. package/dist/Brand.js +107 -0
  7. package/dist/Brand.js.map +1 -0
  8. package/dist/Hash.d.ts +53 -0
  9. package/dist/Hash.d.ts.map +1 -0
  10. package/dist/Hash.js +63 -0
  11. package/dist/Hash.js.map +1 -0
  12. package/dist/SerializeError.d.ts +48 -0
  13. package/dist/SerializeError.d.ts.map +1 -0
  14. package/dist/SerializeError.js +42 -0
  15. package/dist/SerializeError.js.map +1 -0
  16. package/dist/ast/BlockNode.d.ts +429 -0
  17. package/dist/ast/BlockNode.d.ts.map +1 -0
  18. package/dist/ast/BlockNode.js +279 -0
  19. package/dist/ast/BlockNode.js.map +1 -0
  20. package/dist/ast/Document.d.ts +255 -0
  21. package/dist/ast/Document.d.ts.map +1 -0
  22. package/dist/ast/Document.js +80 -0
  23. package/dist/ast/Document.js.map +1 -0
  24. package/dist/ast/InlineNode.d.ts +483 -0
  25. package/dist/ast/InlineNode.d.ts.map +1 -0
  26. package/dist/ast/InlineNode.js +268 -0
  27. package/dist/ast/InlineNode.js.map +1 -0
  28. package/dist/ast/MacroNode.d.ts +214 -0
  29. package/dist/ast/MacroNode.d.ts.map +1 -0
  30. package/dist/ast/MacroNode.js +110 -0
  31. package/dist/ast/MacroNode.js.map +1 -0
  32. package/dist/ast/index.d.ts +10 -0
  33. package/dist/ast/index.d.ts.map +1 -0
  34. package/dist/ast/index.js +14 -0
  35. package/dist/ast/index.js.map +1 -0
  36. package/dist/auth/OAuthEndpoints.d.ts +95 -0
  37. package/dist/auth/OAuthEndpoints.d.ts.map +1 -0
  38. package/dist/auth/OAuthEndpoints.js +112 -0
  39. package/dist/auth/OAuthEndpoints.js.map +1 -0
  40. package/dist/auth/OAuthErrors.d.ts +76 -0
  41. package/dist/auth/OAuthErrors.d.ts.map +1 -0
  42. package/dist/auth/OAuthErrors.js +85 -0
  43. package/dist/auth/OAuthErrors.js.map +1 -0
  44. package/dist/auth/OAuthOperations.d.ts +87 -0
  45. package/dist/auth/OAuthOperations.d.ts.map +1 -0
  46. package/dist/auth/OAuthOperations.js +169 -0
  47. package/dist/auth/OAuthOperations.js.map +1 -0
  48. package/dist/auth/OAuthResponseSchemas.d.ts +65 -0
  49. package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -0
  50. package/dist/auth/OAuthResponseSchemas.js +47 -0
  51. package/dist/auth/OAuthResponseSchemas.js.map +1 -0
  52. package/dist/auth/index.d.ts +11 -0
  53. package/dist/auth/index.d.ts.map +1 -0
  54. package/dist/auth/index.js +16 -0
  55. package/dist/auth/index.js.map +1 -0
  56. package/dist/auth/uuid.d.ts +14 -0
  57. package/dist/auth/uuid.d.ts.map +1 -0
  58. package/dist/auth/uuid.js +14 -0
  59. package/dist/auth/uuid.js.map +1 -0
  60. package/dist/config/ConfigPaths.d.ts +95 -0
  61. package/dist/config/ConfigPaths.d.ts.map +1 -0
  62. package/dist/config/ConfigPaths.js +102 -0
  63. package/dist/config/ConfigPaths.js.map +1 -0
  64. package/dist/config/OAuthSchemas.d.ts +144 -0
  65. package/dist/config/OAuthSchemas.d.ts.map +1 -0
  66. package/dist/config/OAuthSchemas.js +107 -0
  67. package/dist/config/OAuthSchemas.js.map +1 -0
  68. package/dist/config/TokenStorage.d.ts +96 -0
  69. package/dist/config/TokenStorage.d.ts.map +1 -0
  70. package/dist/config/TokenStorage.js +128 -0
  71. package/dist/config/TokenStorage.js.map +1 -0
  72. package/dist/config/index.d.ts +9 -0
  73. package/dist/config/index.d.ts.map +1 -0
  74. package/dist/config/index.js +12 -0
  75. package/dist/config/index.js.map +1 -0
  76. package/dist/index.d.ts +13 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +20 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/parsers/index.d.ts +7 -0
  81. package/dist/parsers/index.d.ts.map +1 -0
  82. package/dist/parsers/index.js +8 -0
  83. package/dist/parsers/index.js.map +1 -0
  84. package/dist/serializers/MarkdownSerializer.d.ts +63 -0
  85. package/dist/serializers/MarkdownSerializer.d.ts.map +1 -0
  86. package/dist/serializers/MarkdownSerializer.js +367 -0
  87. package/dist/serializers/MarkdownSerializer.js.map +1 -0
  88. package/dist/serializers/index.d.ts +7 -0
  89. package/dist/serializers/index.d.ts.map +1 -0
  90. package/dist/serializers/index.js +7 -0
  91. package/dist/serializers/index.js.map +1 -0
  92. package/package.json +86 -0
  93. package/src/Brand.ts +142 -0
  94. package/src/Hash.ts +68 -0
  95. package/src/SerializeError.ts +50 -0
  96. package/src/ast/BlockNode.ts +386 -0
  97. package/src/ast/Document.ts +101 -0
  98. package/src/ast/InlineNode.ts +328 -0
  99. package/src/ast/MacroNode.ts +172 -0
  100. package/src/ast/index.ts +87 -0
  101. package/src/auth/OAuthEndpoints.ts +142 -0
  102. package/src/auth/OAuthErrors.ts +101 -0
  103. package/src/auth/OAuthOperations.ts +276 -0
  104. package/src/auth/OAuthResponseSchemas.ts +70 -0
  105. package/src/auth/index.ts +47 -0
  106. package/src/auth/uuid.ts +14 -0
  107. package/src/config/ConfigPaths.ts +179 -0
  108. package/src/config/OAuthSchemas.ts +146 -0
  109. package/src/config/TokenStorage.ts +228 -0
  110. package/src/config/index.ts +43 -0
  111. package/src/index.ts +34 -0
  112. package/src/parsers/index.ts +8 -0
  113. package/src/serializers/MarkdownSerializer.ts +498 -0
  114. package/src/serializers/index.ts +7 -0
  115. package/test/Brand.test.ts +90 -0
  116. package/test/MarkdownSerializer.test.ts +82 -0
  117. package/test/OAuthEndpoints.test.ts +109 -0
  118. package/test/OAuthOperations.test.ts +315 -0
  119. package/tsconfig.json +11 -0
  120. package/tsconfig.tsbuildinfo +1 -0
  121. package/vitest.config.ts +12 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tagged error types for Atlassian OAuth2 flows.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Step-scoped errors**: {@link OAuthError} carries a `step` field (`"configure" | "authorize" |
7
+ * "token" | "refresh" | "revoke"`) so callers can handle failures per-phase.
8
+ * - **Companion errors**: {@link AuthMissingError} (not logged in) and
9
+ * {@link OAuthNotConfiguredError} (no client credentials) represent pre-flow failures.
10
+ *
11
+ * **Gotchas**
12
+ *
13
+ * - `OAuthError.message` is a computed getter derived from `step` + `cause` — it's not
14
+ * a stored field, so don't destructure it from the constructor.
15
+ *
16
+ * @module
17
+ */
18
+ import * as Data from "effect/Data"
19
+
20
+ /**
21
+ * OAuth flow step for error context.
22
+ *
23
+ * @category Types
24
+ */
25
+ export type OAuthStep = "configure" | "authorize" | "token" | "resources" | "user-info" | "refresh" | "revoke"
26
+
27
+ /**
28
+ * Error during OAuth2 flow.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * import { Effect } from "effect"
33
+ * import { OAuthError } from "@knpkv/atlassian-common/auth"
34
+ *
35
+ * Effect.gen(function* () {
36
+ * // ... oauth operation
37
+ * }).pipe(
38
+ * Effect.catchTag("OAuthError", (error) =>
39
+ * Effect.sync(() => console.error(`OAuth error at ${error.step}: ${error.message}`))
40
+ * )
41
+ * )
42
+ * ```
43
+ *
44
+ * @category Errors
45
+ */
46
+ export class OAuthError extends Data.TaggedError("OAuthError")<{
47
+ readonly step: OAuthStep
48
+ readonly cause?: unknown
49
+ }> {
50
+ override get message(): string {
51
+ if (this.cause instanceof Error) {
52
+ return `OAuth ${this.step} failed: ${this.cause.message}`
53
+ }
54
+ if (typeof this.cause === "string") {
55
+ return `OAuth ${this.step} failed: ${this.cause}`
56
+ }
57
+ return `OAuth ${this.step} failed`
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Error when authentication is missing.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * import { Effect } from "effect"
67
+ * import { AuthMissingError } from "@knpkv/atlassian-common/auth"
68
+ *
69
+ * Effect.gen(function* () {
70
+ * // ... requires auth
71
+ * }).pipe(
72
+ * Effect.catchTag("AuthMissingError", () =>
73
+ * Effect.sync(() => console.error("Please login first"))
74
+ * )
75
+ * )
76
+ * ```
77
+ *
78
+ * @category Errors
79
+ */
80
+ export class AuthMissingError extends Data.TaggedError("AuthMissingError")<{
81
+ readonly tool?: string
82
+ }> {
83
+ override get message(): string {
84
+ const toolPart = this.tool ? ` for ${this.tool}` : ""
85
+ return `Not logged in${toolPart}. Please run 'auth login' first.`
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Error when OAuth is not configured.
91
+ *
92
+ * @category Errors
93
+ */
94
+ export class OAuthNotConfiguredError extends Data.TaggedError("OAuthNotConfiguredError")<{
95
+ readonly tool?: string
96
+ }> {
97
+ override get message(): string {
98
+ const toolPart = this.tool ? `'${this.tool} auth configure'` : "'auth configure'"
99
+ return `OAuth not configured. Run ${toolPart} first.`
100
+ }
101
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Core OAuth2 operations against Atlassian's token and resource endpoints.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Effect-native HTTP**: Every operation takes an `HttpClient` from the Effect
7
+ * context and returns `Effect<A, OAuthError, HttpClient>`. Callers provide the
8
+ * client via `Layer.succeed`.
9
+ * - **Schema-validated responses**: Token and user payloads are decoded through
10
+ * Effect Schema before returning, catching API contract drift at runtime.
11
+ *
12
+ * **Common tasks**
13
+ *
14
+ * - Exchange auth code: {@link exchangeCodeForTokens}
15
+ * - List user sites: {@link getAccessibleResources}
16
+ * - Refresh expired token: {@link refreshToken}
17
+ * - Build storable token: {@link buildOAuthToken}
18
+ *
19
+ * @module
20
+ */
21
+ import * as HttpClient from "@effect/platform/HttpClient"
22
+ import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
23
+ import * as Effect from "effect/Effect"
24
+ import * as Schema from "effect/Schema"
25
+ import { type OAuthConfig, type OAuthToken } from "../config/OAuthSchemas.js"
26
+ import { ME_URL, RESOURCES_URL, REVOKE_URL, TOKEN_URL } from "./OAuthEndpoints.js"
27
+ import { OAuthError } from "./OAuthErrors.js"
28
+ import {
29
+ type AccessibleResource,
30
+ AccessibleResourceSchema,
31
+ type TokenResponse,
32
+ TokenResponseSchema,
33
+ type UserInfo,
34
+ UserInfoSchema
35
+ } from "./OAuthResponseSchemas.js"
36
+
37
+ /**
38
+ * Options for exchanging an authorization code for tokens.
39
+ *
40
+ * @category Types
41
+ */
42
+ export interface ExchangeCodeOptions {
43
+ /** Local callback server port (for redirect_uri) */
44
+ readonly port: number
45
+ /** PKCE code verifier (if PKCE was used in auth request) */
46
+ readonly codeVerifier?: string | undefined
47
+ }
48
+
49
+ /**
50
+ * Exchange authorization code for tokens.
51
+ *
52
+ * @category Operations
53
+ */
54
+ export const exchangeCodeForTokens = (
55
+ code: string,
56
+ config: OAuthConfig,
57
+ options: ExchangeCodeOptions
58
+ ): Effect.Effect<TokenResponse, OAuthError, HttpClient.HttpClient> =>
59
+ Effect.gen(function*() {
60
+ const httpClient = yield* HttpClient.HttpClient
61
+ const tokenBody: Record<string, string> = {
62
+ grant_type: "authorization_code",
63
+ client_id: config.clientId,
64
+ client_secret: config.clientSecret,
65
+ code,
66
+ redirect_uri: `http://localhost:${options.port}/callback`
67
+ }
68
+ if (options.codeVerifier) tokenBody.code_verifier = options.codeVerifier
69
+ const request = yield* HttpClientRequest.post(TOKEN_URL).pipe(
70
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
71
+ HttpClientRequest.bodyJson(tokenBody)
72
+ )
73
+
74
+ const response = yield* httpClient.execute(request)
75
+
76
+ if (response.status >= 400) {
77
+ const text = yield* response.text
78
+ yield* Effect.logDebug(`Token exchange failed (${response.status}): ${text}`)
79
+ return yield* Effect.fail(
80
+ new OAuthError({ step: "token", cause: `HTTP ${response.status}` })
81
+ )
82
+ }
83
+
84
+ const body = yield* response.json
85
+
86
+ return yield* Schema.decodeUnknown(TokenResponseSchema)(body)
87
+ }).pipe(
88
+ Effect.mapError((cause) => new OAuthError({ step: "token", cause }))
89
+ )
90
+
91
+ /**
92
+ * Get accessible resources (sites) for the authenticated user.
93
+ *
94
+ * @param accessToken - OAuth access token
95
+ *
96
+ * @category Operations
97
+ */
98
+ export const getAccessibleResources = (
99
+ accessToken: string
100
+ ): Effect.Effect<ReadonlyArray<AccessibleResource>, OAuthError, HttpClient.HttpClient> =>
101
+ Effect.gen(function*() {
102
+ const httpClient = yield* HttpClient.HttpClient
103
+ const request = HttpClientRequest.get(RESOURCES_URL).pipe(
104
+ HttpClientRequest.setHeader("Authorization", `Bearer ${accessToken}`),
105
+ HttpClientRequest.setHeader("Accept", "application/json")
106
+ )
107
+
108
+ const response = yield* httpClient.execute(request)
109
+
110
+ if (response.status >= 400) {
111
+ const text = yield* response.text
112
+ yield* Effect.logDebug(`Accessible resources failed (${response.status}): ${text}`)
113
+ return yield* Effect.fail(
114
+ new OAuthError({ step: "resources", cause: `HTTP ${response.status}` })
115
+ )
116
+ }
117
+
118
+ const body = yield* response.json
119
+
120
+ return yield* Schema.decodeUnknown(Schema.Array(AccessibleResourceSchema))(body)
121
+ }).pipe(
122
+ Effect.mapError((cause) => new OAuthError({ step: "resources", cause }))
123
+ )
124
+
125
+ /**
126
+ * Get user info from /me endpoint.
127
+ *
128
+ * @param accessToken - OAuth access token
129
+ *
130
+ * @category Operations
131
+ */
132
+ export const getUserInfo = (
133
+ accessToken: string
134
+ ): Effect.Effect<UserInfo, OAuthError, HttpClient.HttpClient> =>
135
+ Effect.gen(function*() {
136
+ const httpClient = yield* HttpClient.HttpClient
137
+ const request = HttpClientRequest.get(ME_URL).pipe(
138
+ HttpClientRequest.setHeader("Authorization", `Bearer ${accessToken}`),
139
+ HttpClientRequest.setHeader("Accept", "application/json")
140
+ )
141
+
142
+ const response = yield* httpClient.execute(request)
143
+
144
+ if (response.status >= 400) {
145
+ const text = yield* response.text
146
+ yield* Effect.logDebug(`User info failed (${response.status}): ${text}`)
147
+ return yield* Effect.fail(
148
+ new OAuthError({ step: "user-info", cause: `HTTP ${response.status}` })
149
+ )
150
+ }
151
+
152
+ const body = yield* response.json
153
+
154
+ return yield* Schema.decodeUnknown(UserInfoSchema)(body)
155
+ }).pipe(
156
+ Effect.mapError((cause) => new OAuthError({ step: "user-info", cause }))
157
+ )
158
+
159
+ /**
160
+ * Refresh an expired OAuth token.
161
+ *
162
+ * @param token - Current OAuth token (with refresh_token)
163
+ * @param config - OAuth client configuration
164
+ *
165
+ * @category Operations
166
+ */
167
+ export const refreshToken = (
168
+ token: OAuthToken,
169
+ config: OAuthConfig
170
+ ): Effect.Effect<OAuthToken, OAuthError, HttpClient.HttpClient> =>
171
+ Effect.gen(function*() {
172
+ const httpClient = yield* HttpClient.HttpClient
173
+ const request = yield* HttpClientRequest.post(TOKEN_URL).pipe(
174
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
175
+ HttpClientRequest.bodyJson({
176
+ grant_type: "refresh_token",
177
+ client_id: config.clientId,
178
+ client_secret: config.clientSecret,
179
+ refresh_token: token.refresh_token
180
+ }),
181
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
182
+ )
183
+
184
+ const response = yield* httpClient.execute(request).pipe(
185
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
186
+ )
187
+
188
+ if (response.status >= 400) {
189
+ const text = yield* response.text.pipe(
190
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
191
+ )
192
+ return yield* Effect.fail(
193
+ new OAuthError({ step: "refresh", cause: `HTTP ${response.status}: ${text}` })
194
+ )
195
+ }
196
+
197
+ const body = yield* response.json.pipe(
198
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
199
+ )
200
+ const tokenResponse = yield* Schema.decodeUnknown(TokenResponseSchema)(body).pipe(
201
+ Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
202
+ )
203
+
204
+ return {
205
+ ...token,
206
+ access_token: tokenResponse.access_token,
207
+ refresh_token: tokenResponse.refresh_token,
208
+ expires_at: Date.now() + tokenResponse.expires_in * 1000,
209
+ scope: tokenResponse.scope
210
+ }
211
+ })
212
+
213
+ /**
214
+ * Revoke an OAuth token.
215
+ *
216
+ * @param token - OAuth token to revoke
217
+ * @param config - OAuth client configuration
218
+ *
219
+ * @category Operations
220
+ */
221
+ export const revokeToken = (
222
+ token: OAuthToken,
223
+ config: OAuthConfig
224
+ ): Effect.Effect<void, OAuthError, HttpClient.HttpClient> =>
225
+ Effect.gen(function*() {
226
+ const httpClient = yield* HttpClient.HttpClient
227
+ const request = yield* HttpClientRequest.post(REVOKE_URL).pipe(
228
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
229
+ HttpClientRequest.bodyJson({
230
+ client_id: config.clientId,
231
+ client_secret: config.clientSecret,
232
+ token: token.refresh_token
233
+ }),
234
+ Effect.mapError((cause) => new OAuthError({ step: "revoke", cause }))
235
+ )
236
+
237
+ const response = yield* httpClient.execute(request).pipe(
238
+ Effect.mapError((cause) => new OAuthError({ step: "revoke", cause }))
239
+ )
240
+
241
+ if (response.status >= 400) {
242
+ return yield* Effect.fail(
243
+ new OAuthError({
244
+ step: "revoke",
245
+ cause: `Token revocation failed with status ${response.status}`
246
+ })
247
+ )
248
+ }
249
+ })
250
+
251
+ /**
252
+ * Build OAuthToken from token response and site info.
253
+ *
254
+ * @param tokenResponse - Token response from exchange
255
+ * @param site - Selected accessible resource
256
+ * @param user - User info
257
+ *
258
+ * @category Utilities
259
+ */
260
+ export const buildOAuthToken = (
261
+ tokenResponse: TokenResponse,
262
+ site: AccessibleResource,
263
+ user: UserInfo
264
+ ): OAuthToken => ({
265
+ access_token: tokenResponse.access_token,
266
+ refresh_token: tokenResponse.refresh_token,
267
+ expires_at: Date.now() + tokenResponse.expires_in * 1000,
268
+ scope: tokenResponse.scope,
269
+ cloud_id: site.id,
270
+ site_url: site.url,
271
+ user: {
272
+ account_id: user.account_id,
273
+ name: user.name,
274
+ email: user.email
275
+ }
276
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Effect Schema definitions for Atlassian OAuth2 API response payloads.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Schema = source of truth**: {@link TokenResponseSchema}, {@link AccessibleResourceSchema},
7
+ * and {@link UserInfoSchema} define the wire format. Companion `type` aliases are derived
8
+ * via `Schema.Schema.Type` — never hand-written.
9
+ *
10
+ * @module
11
+ */
12
+ import * as Schema from "effect/Schema"
13
+
14
+ /**
15
+ * Schema for OAuth2 token response from Atlassian.
16
+ *
17
+ * @category Schema
18
+ */
19
+ export const TokenResponseSchema = Schema.Struct({
20
+ access_token: Schema.String,
21
+ refresh_token: Schema.String,
22
+ expires_in: Schema.Number,
23
+ scope: Schema.String,
24
+ token_type: Schema.String
25
+ })
26
+
27
+ /**
28
+ * Type for OAuth2 token response.
29
+ *
30
+ * @category Types
31
+ */
32
+ export type TokenResponse = Schema.Schema.Type<typeof TokenResponseSchema>
33
+
34
+ /**
35
+ * Schema for accessible resource (site) from Atlassian.
36
+ *
37
+ * @category Schema
38
+ */
39
+ export const AccessibleResourceSchema = Schema.Struct({
40
+ id: Schema.String,
41
+ name: Schema.String,
42
+ url: Schema.String,
43
+ scopes: Schema.Array(Schema.String),
44
+ avatarUrl: Schema.optional(Schema.String)
45
+ })
46
+
47
+ /**
48
+ * Type for accessible resource.
49
+ *
50
+ * @category Types
51
+ */
52
+ export type AccessibleResource = Schema.Schema.Type<typeof AccessibleResourceSchema>
53
+
54
+ /**
55
+ * Schema for user info from /me endpoint.
56
+ *
57
+ * @category Schema
58
+ */
59
+ export const UserInfoSchema = Schema.Struct({
60
+ account_id: Schema.String,
61
+ name: Schema.String,
62
+ email: Schema.String
63
+ })
64
+
65
+ /**
66
+ * Type for user info.
67
+ *
68
+ * @category Types
69
+ */
70
+ export type UserInfo = Schema.Schema.Type<typeof UserInfoSchema>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Barrel export for Atlassian OAuth2 auth utilities.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ // Endpoints and scopes
8
+ export {
9
+ AUTH_URL,
10
+ buildAuthUrl,
11
+ type BuildAuthUrlOptions,
12
+ computeCodeChallenge,
13
+ CONFLUENCE_SCOPES,
14
+ generateCodeVerifier,
15
+ JIRA_SCOPES,
16
+ ME_URL,
17
+ RESOURCES_URL,
18
+ REVOKE_URL,
19
+ TOKEN_URL
20
+ } from "./OAuthEndpoints.js"
21
+
22
+ // Errors
23
+ export { AuthMissingError, OAuthError, OAuthNotConfiguredError, type OAuthStep } from "./OAuthErrors.js"
24
+
25
+ // Operations
26
+ export {
27
+ buildOAuthToken,
28
+ exchangeCodeForTokens,
29
+ type ExchangeCodeOptions,
30
+ getAccessibleResources,
31
+ getUserInfo,
32
+ refreshToken,
33
+ revokeToken
34
+ } from "./OAuthOperations.js"
35
+
36
+ // Response schemas
37
+ export {
38
+ type AccessibleResource,
39
+ AccessibleResourceSchema,
40
+ type TokenResponse,
41
+ TokenResponseSchema,
42
+ type UserInfo,
43
+ UserInfoSchema
44
+ } from "./OAuthResponseSchemas.js"
45
+
46
+ // Utilities
47
+ export { generateUUID } from "./uuid.js"
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Cryptographic UUID v4 generation via Web Crypto API.
3
+ *
4
+ * @internal
5
+ */
6
+ import * as Effect from "effect/Effect"
7
+
8
+ /**
9
+ * Generate a cryptographically secure UUID v4.
10
+ * Uses Web Crypto API randomUUID (Node.js 19+, all modern browsers).
11
+ *
12
+ * @category Utilities
13
+ */
14
+ export const generateUUID = (): Effect.Effect<string> => Effect.sync(() => globalThis.crypto.randomUUID())
@@ -0,0 +1,179 @@
1
+ /**
2
+ * XDG-compliant config path resolution and secure file I/O for Atlassian tools.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Injectable home directory**: {@link HomeDirectoryTag} is an Effect service so tests
7
+ * can substitute a temp dir without touching env vars. {@link HomeDirectoryLive} reads
8
+ * `HOME` / `USERPROFILE` via Effect `Config`.
9
+ * - **XDG hierarchy**: `~/.config/atlassian/<toolName>/` — respects `XDG_CONFIG_HOME` override.
10
+ * - **Secure by default**: {@link ensureConfigDir} creates dirs at `0o700`;
11
+ * {@link writeSecureFile} writes at `0o600`.
12
+ *
13
+ * **Common tasks**
14
+ *
15
+ * - Resolve config dir: {@link getConfigDir}
16
+ * - Get auth file path: {@link getAuthPath}
17
+ * - Write credential files: {@link writeSecureFile}
18
+ *
19
+ * @module
20
+ */
21
+ import type * as Error from "@effect/platform/Error"
22
+ import * as FileSystem from "@effect/platform/FileSystem"
23
+ import * as Path from "@effect/platform/Path"
24
+ import * as Config from "effect/Config"
25
+ import * as Context from "effect/Context"
26
+ import * as Data from "effect/Data"
27
+ import * as Effect from "effect/Effect"
28
+ import * as Layer from "effect/Layer"
29
+ import * as Option from "effect/Option"
30
+
31
+ /**
32
+ * Error when home directory cannot be determined.
33
+ *
34
+ * @category Errors
35
+ */
36
+ export class HomeDirectoryError extends Data.TaggedError("HomeDirectoryError")<{
37
+ readonly cause?: unknown
38
+ }> {
39
+ override get message(): string {
40
+ return "Cannot determine home directory: HOME/USERPROFILE not set"
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Service for getting the home directory.
46
+ * Allows mocking in tests.
47
+ *
48
+ * @category Services
49
+ */
50
+ export interface HomeDirectory {
51
+ readonly get: () => Effect.Effect<string, HomeDirectoryError>
52
+ }
53
+
54
+ /**
55
+ * Tag for the HomeDirectory service.
56
+ *
57
+ * @category Services
58
+ */
59
+ export class HomeDirectoryTag extends Context.Tag("@knpkv/atlassian-common/HomeDirectory")<
60
+ HomeDirectoryTag,
61
+ HomeDirectory
62
+ >() {}
63
+
64
+ const HomeConfig = Config.option(Config.string("HOME")).pipe(
65
+ Config.orElse(() => Config.option(Config.string("USERPROFILE")))
66
+ )
67
+
68
+ /**
69
+ * Default implementation using HOME/USERPROFILE env vars.
70
+ *
71
+ * @category Layers
72
+ */
73
+ export const HomeDirectoryLive: Layer.Layer<HomeDirectoryTag> = Layer.succeed(
74
+ HomeDirectoryTag,
75
+ {
76
+ get: () =>
77
+ Effect.gen(function*() {
78
+ const opt = yield* Effect.orDie(HomeConfig)
79
+ return yield* Option.match(opt, {
80
+ onNone: () => Effect.fail(new HomeDirectoryError({})),
81
+ onSome: (home) => Effect.succeed(home)
82
+ })
83
+ })
84
+ }
85
+ )
86
+
87
+ const XdgConfigHome = Config.option(Config.string("XDG_CONFIG_HOME"))
88
+
89
+ /**
90
+ * XDG config directory for Atlassian tools.
91
+ * Returns ~/.config/atlassian by default.
92
+ *
93
+ * @category Utilities
94
+ */
95
+ export const getConfigDir = (
96
+ toolName?: string
97
+ ): Effect.Effect<string, HomeDirectoryError, HomeDirectoryTag | Path.Path> =>
98
+ Effect.gen(function*() {
99
+ const homeDir = yield* HomeDirectoryTag
100
+ const pathSvc = yield* Path.Path
101
+ const home = yield* homeDir.get()
102
+
103
+ // Use XDG_CONFIG_HOME if set, otherwise ~/.config
104
+ const xdgOpt = yield* Effect.orDie(XdgConfigHome)
105
+ const xdgConfig = Option.getOrElse(xdgOpt, () => pathSvc.join(home, ".config"))
106
+ const baseDir = pathSvc.join(xdgConfig, "atlassian")
107
+
108
+ return toolName ? pathSvc.join(baseDir, toolName) : baseDir
109
+ })
110
+
111
+ /**
112
+ * Get auth file path for a specific tool.
113
+ *
114
+ * @category Utilities
115
+ */
116
+ export const getAuthPath = (
117
+ toolName: string
118
+ ): Effect.Effect<string, HomeDirectoryError, HomeDirectoryTag | Path.Path> =>
119
+ Effect.gen(function*() {
120
+ const path = yield* Path.Path
121
+ const configDir = yield* getConfigDir(toolName)
122
+ return path.join(configDir, "auth.json")
123
+ })
124
+
125
+ /**
126
+ * Get OAuth config file path for a specific tool.
127
+ *
128
+ * @category Utilities
129
+ */
130
+ export const getOAuthConfigPath = (
131
+ toolName: string
132
+ ): Effect.Effect<string, HomeDirectoryError, HomeDirectoryTag | Path.Path> =>
133
+ Effect.gen(function*() {
134
+ const path = yield* Path.Path
135
+ const configDir = yield* getConfigDir(toolName)
136
+ return path.join(configDir, "oauth.json")
137
+ })
138
+
139
+ /**
140
+ * Ensure config directory exists with secure permissions.
141
+ *
142
+ * @category Utilities
143
+ */
144
+ export const ensureConfigDir = (
145
+ toolName: string
146
+ ): Effect.Effect<
147
+ string,
148
+ HomeDirectoryError | Error.PlatformError,
149
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
150
+ > =>
151
+ Effect.gen(function*() {
152
+ const fs = yield* FileSystem.FileSystem
153
+ const configDir = yield* getConfigDir(toolName)
154
+
155
+ yield* fs.makeDirectory(configDir, { recursive: true })
156
+
157
+ // Set secure permissions (owner only)
158
+ yield* fs.chmod(configDir, 0o700)
159
+
160
+ return configDir
161
+ })
162
+
163
+ /**
164
+ * Write file with secure permissions (600).
165
+ *
166
+ * @category Utilities
167
+ */
168
+ export const writeSecureFile = (
169
+ filePath: string,
170
+ content: string
171
+ ): Effect.Effect<void, Error.PlatformError, FileSystem.FileSystem> =>
172
+ Effect.gen(function*() {
173
+ const fs = yield* FileSystem.FileSystem
174
+
175
+ yield* fs.writeFileString(filePath, content)
176
+
177
+ // Set secure permissions (owner only)
178
+ yield* fs.chmod(filePath, 0o600)
179
+ })