@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,109 @@
1
+ import { it as effectIt } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import { describe, expect, it } from "vitest"
4
+ import { AUTH_URL, buildAuthUrl, computeCodeChallenge, generateCodeVerifier } from "../src/auth/OAuthEndpoints.js"
5
+
6
+ describe("OAuthEndpoints", () => {
7
+ // ---- PKCE ----
8
+
9
+ describe("generateCodeVerifier", () => {
10
+ // 32 random bytes base64url-encoded = 43 chars; verifies output shape for RFC 7636 compliance
11
+ it("returns a base64url string of 43 characters (32 random bytes)", () => {
12
+ const verifier = generateCodeVerifier()
13
+ expect(verifier).toHaveLength(43)
14
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
15
+ })
16
+
17
+ // PKCE verifiers must be unique per auth request — reuse enables replay attacks
18
+ it("produces unique values", () => {
19
+ const a = generateCodeVerifier()
20
+ const b = generateCodeVerifier()
21
+ expect(a).not.toBe(b)
22
+ })
23
+ })
24
+
25
+ describe("computeCodeChallenge", () => {
26
+ // Known test vector from RFC 7636 appendix B — proves SHA-256 + base64url is correct
27
+ effectIt.effect("matches RFC 7636 appendix B test vector", () =>
28
+ Effect.gen(function*() {
29
+ const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
30
+ const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
31
+ const challenge = yield* computeCodeChallenge(verifier)
32
+ expect(challenge).toBe(expected)
33
+ }))
34
+
35
+ // Same verifier must always produce same challenge — server verifies this on token exchange
36
+ effectIt.effect("is deterministic", () =>
37
+ Effect.gen(function*() {
38
+ const v = generateCodeVerifier()
39
+ const a = yield* computeCodeChallenge(v)
40
+ const b = yield* computeCodeChallenge(v)
41
+ expect(a).toBe(b)
42
+ }))
43
+
44
+ // base64url must not contain +, /, = — these break URL query params
45
+ effectIt.effect("produces base64url output (no +, /, =)", () =>
46
+ Effect.gen(function*() {
47
+ const challenge = yield* computeCodeChallenge("test-verifier-string")
48
+ expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/)
49
+ }))
50
+ })
51
+
52
+ // ---- buildAuthUrl ----
53
+
54
+ describe("buildAuthUrl", () => {
55
+ // Verifies all required OAuth2 params (audience, client_id, scope, redirect_uri, state, response_type, prompt)
56
+ it("includes required parameters", () => {
57
+ const url = buildAuthUrl({
58
+ clientId: "cid",
59
+ state: "state-123",
60
+ port: 9876,
61
+ scopes: ["read:jira-work", "offline_access"]
62
+ })
63
+
64
+ const parsed = new URL(url)
65
+ expect(parsed.origin + parsed.pathname).toBe(AUTH_URL)
66
+ expect(parsed.searchParams.get("client_id")).toBe("cid")
67
+ expect(parsed.searchParams.get("state")).toBe("state-123")
68
+ expect(parsed.searchParams.get("redirect_uri")).toBe("http://localhost:9876/callback")
69
+ expect(parsed.searchParams.get("scope")).toBe("read:jira-work offline_access")
70
+ expect(parsed.searchParams.get("response_type")).toBe("code")
71
+ expect(parsed.searchParams.get("audience")).toBe("api.atlassian.com")
72
+ expect(parsed.searchParams.get("prompt")).toBe("consent")
73
+ })
74
+
75
+ // PKCE is optional — omitting codeChallenge must not add params (backwards compat)
76
+ it("omits PKCE params when codeChallenge not provided", () => {
77
+ const url = buildAuthUrl({
78
+ clientId: "cid",
79
+ state: "s",
80
+ port: 3000,
81
+ scopes: ["read:me"]
82
+ })
83
+
84
+ const parsed = new URL(url)
85
+ expect(parsed.searchParams.has("code_challenge")).toBe(false)
86
+ expect(parsed.searchParams.has("code_challenge_method")).toBe(false)
87
+ })
88
+
89
+ // When PKCE is used, both code_challenge and method=S256 must appear for server to validate
90
+ effectIt.effect(
91
+ "includes code_challenge and code_challenge_method=S256 when provided",
92
+ () =>
93
+ Effect.gen(function*() {
94
+ const challenge = yield* computeCodeChallenge("my-verifier")
95
+ const url = buildAuthUrl({
96
+ clientId: "cid",
97
+ state: "s",
98
+ port: 3000,
99
+ scopes: ["read:me"],
100
+ codeChallenge: challenge
101
+ })
102
+
103
+ const parsed = new URL(url)
104
+ expect(parsed.searchParams.get("code_challenge")).toBe(challenge)
105
+ expect(parsed.searchParams.get("code_challenge_method")).toBe("S256")
106
+ })
107
+ )
108
+ })
109
+ })
@@ -0,0 +1,315 @@
1
+ import * as HttpClient from "@effect/platform/HttpClient"
2
+ import type * as HttpClientRequest from "@effect/platform/HttpClientRequest"
3
+ import * as HttpClientResponse from "@effect/platform/HttpClientResponse"
4
+ import { describe, it } from "@effect/vitest"
5
+ import * as Effect from "effect/Effect"
6
+ import * as Layer from "effect/Layer"
7
+ import { expect } from "vitest"
8
+ import { OAuthError } from "../src/auth/OAuthErrors.js"
9
+ import {
10
+ buildOAuthToken,
11
+ exchangeCodeForTokens,
12
+ getAccessibleResources,
13
+ getUserInfo,
14
+ refreshToken,
15
+ revokeToken
16
+ } from "../src/auth/OAuthOperations.js"
17
+ import type { OAuthConfig, OAuthToken } from "../src/config/OAuthSchemas.js"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const createMockHttpClient = (
24
+ responses: Array<{ status: number; body: unknown }>
25
+ ) => {
26
+ let requestIndex = 0
27
+ const capturedRequests: Array<HttpClientRequest.HttpClientRequest> = []
28
+
29
+ const mockClient: HttpClient.HttpClient = HttpClient.make((request) =>
30
+ Effect.gen(function*() {
31
+ capturedRequests.push(request)
32
+ const response = responses[requestIndex] ?? { status: 200, body: {} }
33
+ requestIndex++
34
+ return HttpClientResponse.fromWeb(
35
+ request,
36
+ new Response(
37
+ JSON.stringify(response.body),
38
+ { status: response.status, headers: { "content-type": "application/json" } }
39
+ )
40
+ )
41
+ })
42
+ )
43
+
44
+ return { mockClient, capturedRequests }
45
+ }
46
+
47
+ const provide = (client: HttpClient.HttpClient) => Effect.provide(Layer.succeed(HttpClient.HttpClient, client))
48
+
49
+ const validTokenBody = {
50
+ access_token: "access-123",
51
+ refresh_token: "refresh-456",
52
+ expires_in: 3600,
53
+ scope: "read:jira-work",
54
+ token_type: "Bearer"
55
+ }
56
+
57
+ const oauthConfig: OAuthConfig = {
58
+ clientId: "cid",
59
+ clientSecret: "csecret"
60
+ }
61
+
62
+ const oauthToken: OAuthToken = {
63
+ access_token: "old-access",
64
+ refresh_token: "old-refresh",
65
+ expires_at: Date.now() - 1000,
66
+ scope: "read:jira-work",
67
+ cloud_id: "cloud-1",
68
+ site_url: "https://site.atlassian.net"
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Tests
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe("OAuthOperations", () => {
76
+ // ---- exchangeCodeForTokens ----
77
+
78
+ describe("exchangeCodeForTokens", () => {
79
+ // Verifies grant_type, client_id, client_secret, code, redirect_uri are sent — the 5 required fields for token exchange
80
+ // Also confirms code_verifier is absent when PKCE not used
81
+ it.effect("sends correct body without code_verifier", () =>
82
+ Effect.gen(function*() {
83
+ const { capturedRequests, mockClient } = createMockHttpClient([
84
+ { status: 200, body: validTokenBody }
85
+ ])
86
+
87
+ const result = yield* exchangeCodeForTokens("auth-code", oauthConfig, { port: 9876 }).pipe(
88
+ provide(mockClient)
89
+ )
90
+
91
+ expect(result.access_token).toBe("access-123")
92
+ expect(capturedRequests).toHaveLength(1)
93
+ const req = capturedRequests[0]!
94
+ expect(req.url).toContain("auth.atlassian.com/oauth/token")
95
+
96
+ // body is an Effect — decode from the captured request
97
+ const body = yield* req.body._tag === "Uint8Array"
98
+ ? Effect.succeed(JSON.parse(new TextDecoder().decode(req.body.body)))
99
+ : Effect.succeed({})
100
+
101
+ expect(body.grant_type).toBe("authorization_code")
102
+ expect(body.client_id).toBe("cid")
103
+ expect(body.client_secret).toBe("csecret")
104
+ expect(body.code).toBe("auth-code")
105
+ expect(body.redirect_uri).toBe("http://localhost:9876/callback")
106
+ expect(body.code_verifier).toBeUndefined()
107
+ }))
108
+
109
+ // PKCE requires code_verifier on token exchange — server checks SHA256(verifier) == challenge from auth URL
110
+ it.effect("includes code_verifier when provided", () =>
111
+ Effect.gen(function*() {
112
+ const { capturedRequests, mockClient } = createMockHttpClient([
113
+ { status: 200, body: validTokenBody }
114
+ ])
115
+
116
+ yield* exchangeCodeForTokens("auth-code", oauthConfig, { port: 9876, codeVerifier: "my-verifier" }).pipe(
117
+ provide(mockClient)
118
+ )
119
+
120
+ const req = capturedRequests[0]!
121
+ const body = yield* req.body._tag === "Uint8Array"
122
+ ? Effect.succeed(JSON.parse(new TextDecoder().decode(req.body.body)))
123
+ : Effect.succeed({})
124
+
125
+ expect(body.code_verifier).toBe("my-verifier")
126
+ }))
127
+
128
+ // Token exchange failures (invalid code, expired code) must surface as OAuthError with step="token"
129
+ it.effect("returns OAuthError with step=token on HTTP 400", () =>
130
+ Effect.gen(function*() {
131
+ const { mockClient } = createMockHttpClient([
132
+ { status: 400, body: { error: "invalid_grant" } }
133
+ ])
134
+
135
+ const exit = yield* exchangeCodeForTokens("bad-code", oauthConfig, { port: 9876 }).pipe(
136
+ provide(mockClient),
137
+ Effect.flip
138
+ )
139
+
140
+ expect(exit).toBeInstanceOf(OAuthError)
141
+ expect(exit.step).toBe("token")
142
+ }))
143
+ })
144
+
145
+ // ---- getAccessibleResources ----
146
+
147
+ describe("getAccessibleResources", () => {
148
+ // Verifies Bearer token is applied to Authorization header and response is decoded into typed array
149
+ it.effect("applies Bearer token and decodes response", () =>
150
+ Effect.gen(function*() {
151
+ const { capturedRequests, mockClient } = createMockHttpClient([
152
+ {
153
+ status: 200,
154
+ body: [
155
+ { id: "cloud-1", name: "My Site", url: "https://site.atlassian.net", scopes: ["read:jira-work"] }
156
+ ]
157
+ }
158
+ ])
159
+
160
+ const result = yield* getAccessibleResources("tok-abc").pipe(provide(mockClient))
161
+
162
+ expect(result).toHaveLength(1)
163
+ expect(result[0]!.id).toBe("cloud-1")
164
+
165
+ const req = capturedRequests[0]!
166
+ expect(req.headers.authorization).toBe("Bearer tok-abc")
167
+ }))
168
+
169
+ // Resources failures must use step="resources" (not "authorize") so callers can distinguish from other phases
170
+ it.effect("returns OAuthError with step=resources on HTTP 401", () =>
171
+ Effect.gen(function*() {
172
+ const { mockClient } = createMockHttpClient([{ status: 401, body: {} }])
173
+
174
+ const exit = yield* getAccessibleResources("bad-tok").pipe(
175
+ provide(mockClient),
176
+ Effect.flip
177
+ )
178
+
179
+ expect(exit).toBeInstanceOf(OAuthError)
180
+ expect(exit.step).toBe("resources")
181
+ }))
182
+ })
183
+
184
+ // ---- getUserInfo ----
185
+
186
+ describe("getUserInfo", () => {
187
+ // Verifies Bearer token and correct decoding of account_id, name, email from /me endpoint
188
+ it.effect("applies Bearer token and decodes response", () =>
189
+ Effect.gen(function*() {
190
+ const { capturedRequests, mockClient } = createMockHttpClient([
191
+ { status: 200, body: { account_id: "uid-1", name: "Alice", email: "a@b.com" } }
192
+ ])
193
+
194
+ const result = yield* getUserInfo("tok-xyz").pipe(provide(mockClient))
195
+
196
+ expect(result.account_id).toBe("uid-1")
197
+ expect(result.name).toBe("Alice")
198
+ expect(result.email).toBe("a@b.com")
199
+
200
+ const req = capturedRequests[0]!
201
+ expect(req.headers.authorization).toBe("Bearer tok-xyz")
202
+ }))
203
+
204
+ // User info failures must use step="user-info" (not "authorize") for per-phase error handling
205
+ it.effect("returns OAuthError with step=user-info on HTTP 403", () =>
206
+ Effect.gen(function*() {
207
+ const { mockClient } = createMockHttpClient([{ status: 403, body: {} }])
208
+
209
+ const exit = yield* getUserInfo("bad-tok").pipe(
210
+ provide(mockClient),
211
+ Effect.flip
212
+ )
213
+
214
+ expect(exit).toBeInstanceOf(OAuthError)
215
+ expect(exit.step).toBe("user-info")
216
+ }))
217
+ })
218
+
219
+ // ---- refreshToken ----
220
+
221
+ describe("refreshToken", () => {
222
+ // Verifies grant_type=refresh_token is sent and returned token preserves cloud_id/site_url from original
223
+ it.effect("sends refresh_token and returns updated OAuthToken", () =>
224
+ Effect.gen(function*() {
225
+ const { capturedRequests, mockClient } = createMockHttpClient([
226
+ { status: 200, body: { ...validTokenBody, access_token: "new-access", refresh_token: "new-refresh" } }
227
+ ])
228
+
229
+ const result = yield* refreshToken(oauthToken, oauthConfig).pipe(provide(mockClient))
230
+
231
+ expect(result.access_token).toBe("new-access")
232
+ expect(result.refresh_token).toBe("new-refresh")
233
+ // preserves existing fields
234
+ expect(result.cloud_id).toBe("cloud-1")
235
+ expect(result.site_url).toBe("https://site.atlassian.net")
236
+ expect(result.expires_at).toBeGreaterThan(Date.now())
237
+
238
+ const req = capturedRequests[0]!
239
+ const body = yield* req.body._tag === "Uint8Array"
240
+ ? Effect.succeed(JSON.parse(new TextDecoder().decode(req.body.body)))
241
+ : Effect.succeed({})
242
+
243
+ expect(body.grant_type).toBe("refresh_token")
244
+ expect(body.refresh_token).toBe("old-refresh")
245
+ }))
246
+
247
+ // Refresh failures (revoked token, expired refresh) must surface as step="refresh"
248
+ it.effect("returns OAuthError with step=refresh on HTTP 400", () =>
249
+ Effect.gen(function*() {
250
+ const { mockClient } = createMockHttpClient([
251
+ { status: 400, body: { error: "invalid_grant" } }
252
+ ])
253
+
254
+ const exit = yield* refreshToken(oauthToken, oauthConfig).pipe(
255
+ provide(mockClient),
256
+ Effect.flip
257
+ )
258
+
259
+ expect(exit).toBeInstanceOf(OAuthError)
260
+ expect(exit.step).toBe("refresh")
261
+ }))
262
+ })
263
+
264
+ // ---- revokeToken ----
265
+
266
+ describe("revokeToken", () => {
267
+ // Verifies revoke hits the correct endpoint — logout must invalidate server-side token
268
+ it.effect("sends revoke request", () =>
269
+ Effect.gen(function*() {
270
+ const { capturedRequests, mockClient } = createMockHttpClient([
271
+ { status: 200, body: {} }
272
+ ])
273
+
274
+ yield* revokeToken(oauthToken, oauthConfig).pipe(provide(mockClient))
275
+
276
+ expect(capturedRequests).toHaveLength(1)
277
+ const req = capturedRequests[0]!
278
+ expect(req.url).toContain("auth.atlassian.com/oauth/revoke")
279
+ }))
280
+
281
+ // Revoke failures must surface as step="revoke" for caller error handling
282
+ it.effect("returns OAuthError with step=revoke on HTTP 400", () =>
283
+ Effect.gen(function*() {
284
+ const { mockClient } = createMockHttpClient([{ status: 400, body: {} }])
285
+
286
+ const exit = yield* revokeToken(oauthToken, oauthConfig).pipe(
287
+ provide(mockClient),
288
+ Effect.flip
289
+ )
290
+
291
+ expect(exit).toBeInstanceOf(OAuthError)
292
+ expect(exit.step).toBe("revoke")
293
+ }))
294
+ })
295
+
296
+ // ---- buildOAuthToken ----
297
+
298
+ describe("buildOAuthToken", () => {
299
+ // Pure assembly function — verifies token response + site + user are merged correctly with computed expires_at
300
+ it("assembles OAuthToken from response + site + user", () => {
301
+ const token = buildOAuthToken(
302
+ validTokenBody,
303
+ { id: "cloud-1", name: "Site", url: "https://site.atlassian.net", scopes: [] },
304
+ { account_id: "uid-1", name: "Alice", email: "a@b.com" }
305
+ )
306
+
307
+ expect(token.access_token).toBe("access-123")
308
+ expect(token.refresh_token).toBe("refresh-456")
309
+ expect(token.cloud_id).toBe("cloud-1")
310
+ expect(token.site_url).toBe("https://site.atlassian.net")
311
+ expect(token.user).toEqual({ account_id: "uid-1", name: "Alice", email: "a@b.com" })
312
+ expect(token.expires_at).toBeGreaterThan(Date.now() - 1000)
313
+ })
314
+ })
315
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.jsonc",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "types": ["node"]
8
+ },
9
+ "include": ["src/**/*"],
10
+ "exclude": ["node_modules", "dist", "test"]
11
+ }