@knpkv/atlassian-common 0.4.0 → 1.1.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 (73) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +4 -3
  3. package/dist/Brand.d.ts.map +1 -1
  4. package/dist/Brand.js +4 -2
  5. package/dist/Brand.js.map +1 -1
  6. package/dist/Hash.d.ts +4 -4
  7. package/dist/Hash.js +6 -6
  8. package/dist/Hash.js.map +1 -1
  9. package/dist/ast/Document.d.ts +2 -2
  10. package/dist/ast/Document.d.ts.map +1 -1
  11. package/dist/ast/Document.js +1 -1
  12. package/dist/ast/Document.js.map +1 -1
  13. package/dist/ast/MacroNode.d.ts +4 -4
  14. package/dist/ast/MacroNode.d.ts.map +1 -1
  15. package/dist/ast/MacroNode.js +8 -1
  16. package/dist/ast/MacroNode.js.map +1 -1
  17. package/dist/attachments.d.ts +42 -0
  18. package/dist/attachments.d.ts.map +1 -0
  19. package/dist/attachments.js +51 -0
  20. package/dist/attachments.js.map +1 -0
  21. package/dist/auth/OAuthEndpoints.d.ts +6 -4
  22. package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
  23. package/dist/auth/OAuthEndpoints.js +14 -7
  24. package/dist/auth/OAuthEndpoints.js.map +1 -1
  25. package/dist/auth/OAuthErrors.d.ts.map +1 -1
  26. package/dist/auth/OAuthErrors.js +4 -3
  27. package/dist/auth/OAuthErrors.js.map +1 -1
  28. package/dist/auth/OAuthOperations.d.ts +0 -20
  29. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  30. package/dist/auth/OAuthOperations.js +3 -1
  31. package/dist/auth/OAuthOperations.js.map +1 -1
  32. package/dist/auth/uuid.d.ts +5 -3
  33. package/dist/auth/uuid.d.ts.map +1 -1
  34. package/dist/auth/uuid.js +7 -3
  35. package/dist/auth/uuid.js.map +1 -1
  36. package/dist/bin.js +1 -1
  37. package/dist/bin.js.map +1 -1
  38. package/dist/config/AuthProfiles.js.map +1 -1
  39. package/dist/config/ProfileManager.d.ts +3 -20
  40. package/dist/config/ProfileManager.d.ts.map +1 -1
  41. package/dist/config/ProfileManager.js +2 -1
  42. package/dist/config/ProfileManager.js.map +1 -1
  43. package/dist/config/TokenStorage.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/serializers/MarkdownSerializer.d.ts.map +1 -1
  49. package/dist/serializers/MarkdownSerializer.js +9 -6
  50. package/dist/serializers/MarkdownSerializer.js.map +1 -1
  51. package/package.json +12 -8
  52. package/src/Brand.ts +4 -2
  53. package/src/Hash.ts +6 -6
  54. package/src/ast/Document.ts +1 -1
  55. package/src/ast/MacroNode.ts +8 -1
  56. package/src/attachments.ts +98 -0
  57. package/src/auth/OAuthEndpoints.ts +20 -11
  58. package/src/auth/OAuthErrors.ts +4 -3
  59. package/src/auth/OAuthOperations.ts +3 -1
  60. package/src/auth/uuid.ts +9 -3
  61. package/src/bin.ts +1 -1
  62. package/src/config/AuthProfiles.ts +1 -1
  63. package/src/config/ProfileManager.ts +21 -9
  64. package/src/config/TokenStorage.ts +1 -1
  65. package/src/index.ts +3 -0
  66. package/src/serializers/MarkdownSerializer.ts +22 -37
  67. package/test/AuthProfiles.test.ts +1 -1
  68. package/test/OAuthEndpoints.test.ts +34 -16
  69. package/test/OAuthOperations.test.ts +5 -1
  70. package/test/ProfileManager.test.ts +1 -1
  71. package/test/attachments.test.ts +75 -0
  72. package/test/fixtures/attachments/inline-attachment.svg +6 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared attachment reference helpers for Atlassian Markdown surfaces.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export interface AttachmentPreviewInput {
8
+ readonly id?: string | undefined
9
+ readonly filename: string
10
+ readonly url?: string | undefined
11
+ readonly mediaType?: string | null | undefined
12
+ readonly mimeType?: string | null | undefined
13
+ readonly size?: number | null | undefined
14
+ }
15
+
16
+ export interface AttachmentReferenceInput {
17
+ readonly id: string
18
+ readonly filename: string
19
+ readonly url: string
20
+ readonly mediaType: string | null
21
+ readonly size: number | null
22
+ }
23
+
24
+ export interface AttachmentPlaceholderReplacement {
25
+ readonly content: string
26
+ readonly replacements: number
27
+ }
28
+
29
+ export interface AttachmentPlaceholderMatch {
30
+ readonly label: string
31
+ readonly isImage: boolean
32
+ }
33
+
34
+ const IMAGE_EXTENSION_RE = /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i
35
+
36
+ /**
37
+ * Returns true when an attachment should render as a Markdown image preview.
38
+ * SVG is previewable even when the remote API reports a generic content type.
39
+ */
40
+ export const isPreviewableAttachment = (attachment: AttachmentPreviewInput): boolean => {
41
+ const type = attachment.mediaType ?? attachment.mimeType ?? ""
42
+ return type.toLowerCase().startsWith("image/") || IMAGE_EXTENSION_RE.test(attachment.filename)
43
+ }
44
+
45
+ export const normalizeAttachmentMediaType = (
46
+ mediaType: string | null | undefined,
47
+ mimeType?: string | null | undefined
48
+ ): string | null => {
49
+ const value = mediaType ?? mimeType
50
+ return value && value.trim().length > 0 ? value : null
51
+ }
52
+
53
+ export const renderAttachmentMarkdown = (
54
+ attachment: AttachmentReferenceInput,
55
+ options?: { readonly label?: string | null | undefined; readonly metadata?: string | null | undefined }
56
+ ): string => {
57
+ const label = escapeMarkdownLabel(options?.label?.trim() || attachment.filename)
58
+ const destination = safeMarkdownDestination(attachment.url)
59
+ const reference = isPreviewableAttachment(attachment)
60
+ ? `![${label}](${destination})`
61
+ : `[${label}](${destination})`
62
+ return options?.metadata ? `${options.metadata}\n${reference}` : reference
63
+ }
64
+
65
+ export const replaceAttachmentPlaceholder = (
66
+ content: string,
67
+ placeholderPath: string,
68
+ renderedAttachment: string | ((match: AttachmentPlaceholderMatch) => string),
69
+ options?: { readonly replaceAll?: boolean | undefined }
70
+ ): AttachmentPlaceholderReplacement => {
71
+ const escapedPath = escapeRegExp(placeholderPath)
72
+ const placeholder = new RegExp(`!?\\[([^\\]]*)\\]\\(${escapedPath}\\)`, "g")
73
+ let replacements = 0
74
+ const replaced = content.replace(placeholder, (match, label: string) => {
75
+ replacements++
76
+ return typeof renderedAttachment === "string"
77
+ ? renderedAttachment
78
+ : renderedAttachment({ isImage: match.startsWith("!"), label })
79
+ })
80
+ if (!options?.replaceAll && replacements > 1) {
81
+ return { content, replacements }
82
+ }
83
+ return { content: replaced, replacements }
84
+ }
85
+
86
+ const escapeMarkdownLabel = (value: string): string =>
87
+ value
88
+ .replace(/\\/g, "\\\\")
89
+ .replace(/\n+/g, " ")
90
+ .replace(/\[/g, "\\[")
91
+ .replace(/]/g, "\\]")
92
+
93
+ const safeMarkdownDestination = (value: string): string =>
94
+ /[\s()<>]/.test(value)
95
+ ? `<${value.replace(/\n/g, "%0A").replace(/</g, "%3C").replace(/>/g, "%3E")}>`
96
+ : value
97
+
98
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
@@ -12,8 +12,10 @@
12
12
  *
13
13
  * @module
14
14
  */
15
+ import * as Crypto from "effect/Crypto"
15
16
  import * as Effect from "effect/Effect"
16
17
  import * as Encoding from "effect/Encoding"
18
+ import type * as PlatformError from "effect/PlatformError"
17
19
 
18
20
  /**
19
21
  * OAuth2 authorization endpoint.
@@ -59,6 +61,8 @@ export const CONFLUENCE_SCOPES = [
59
61
  "read:page:confluence",
60
62
  "write:page:confluence",
61
63
  "delete:page:confluence",
64
+ "read:attachment:confluence",
65
+ "write:attachment:confluence",
62
66
  "read:me",
63
67
  "offline_access"
64
68
  ]
@@ -120,23 +124,28 @@ export const buildAuthUrl = (options: BuildAuthUrlOptions): string => {
120
124
 
121
125
  /**
122
126
  * Generate a cryptographically random PKCE code verifier (RFC 7636).
123
- * Uses Web Crypto API (available in Node 18+, Deno, browsers).
127
+ * Uses Effect's platform Crypto service.
124
128
  *
125
129
  * @category PKCE
126
130
  */
127
- export const generateCodeVerifier = (): string => {
128
- const bytes = new Uint8Array(32)
129
- globalThis.crypto.getRandomValues(bytes)
130
- return Encoding.encodeBase64Url(bytes)
131
- }
131
+ export const generateCodeVerifier = (): Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto> =>
132
+ Effect.gen(function*() {
133
+ const cryptoService = yield* Crypto.Crypto
134
+ const bytes = yield* cryptoService.randomBytes(32)
135
+ return Encoding.encodeBase64Url(bytes)
136
+ })
132
137
 
133
138
  /**
134
139
  * Compute S256 code challenge from a code verifier (RFC 7636).
135
- * Uses Web Crypto `subtle.digest` — returns Effect since digest is async.
140
+ * Uses Effect's platform Crypto service.
136
141
  *
137
142
  * @category PKCE
138
143
  */
139
- export const computeCodeChallenge = (verifier: string): Effect.Effect<string> =>
140
- Effect.promise(() => globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).pipe(
141
- Effect.map((hash) => Encoding.encodeBase64Url(new Uint8Array(hash)))
142
- )
144
+ export const computeCodeChallenge = (
145
+ verifier: string
146
+ ): Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto> =>
147
+ Effect.gen(function*() {
148
+ const cryptoService = yield* Crypto.Crypto
149
+ const hash = yield* cryptoService.digest("SHA-256", new TextEncoder().encode(verifier))
150
+ return Encoding.encodeBase64Url(hash)
151
+ })
@@ -16,6 +16,7 @@
16
16
  * @module
17
17
  */
18
18
  import * as Data from "effect/Data"
19
+ import * as Predicate from "effect/Predicate"
19
20
 
20
21
  /**
21
22
  * OAuth flow step for error context.
@@ -48,12 +49,12 @@ export class OAuthError extends Data.TaggedError("OAuthError")<{
48
49
  readonly cause?: unknown
49
50
  }> {
50
51
  override get message(): string {
51
- if (this.cause instanceof Error) {
52
- return `OAuth ${this.step} failed: ${this.cause.message}`
53
- }
54
52
  if (typeof this.cause === "string") {
55
53
  return `OAuth ${this.step} failed: ${this.cause}`
56
54
  }
55
+ if (Predicate.hasProperty(this.cause, "message")) {
56
+ return `OAuth ${this.step} failed: ${String(this.cause.message)}`
57
+ }
57
58
  return `OAuth ${this.step} failed`
58
59
  }
59
60
  }
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * @module
20
20
  */
21
+ import * as Clock from "effect/Clock"
21
22
  import * as Effect from "effect/Effect"
22
23
  import * as Schema from "effect/Schema"
23
24
  import { HttpClient, HttpClientRequest } from "effect/unstable/http"
@@ -199,12 +200,13 @@ export const refreshToken = (
199
200
  const tokenResponse = yield* Schema.decodeUnknownEffect(TokenResponseSchema)(body).pipe(
200
201
  Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
201
202
  )
203
+ const nowMs = yield* Clock.currentTimeMillis
202
204
 
203
205
  return {
204
206
  ...token,
205
207
  access_token: tokenResponse.access_token,
206
208
  refresh_token: tokenResponse.refresh_token,
207
- expires_at: Date.now() + tokenResponse.expires_in * 1000,
209
+ expires_at: nowMs + tokenResponse.expires_in * 1000,
208
210
  scope: tokenResponse.scope
209
211
  }
210
212
  })
package/src/auth/uuid.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  /**
2
- * Cryptographic UUID v4 generation via Web Crypto API.
2
+ * Cryptographic UUID v4 generation via Effect's platform Crypto service.
3
3
  *
4
4
  * @internal
5
5
  */
6
+ import * as Crypto from "effect/Crypto"
6
7
  import * as Effect from "effect/Effect"
8
+ import type * as PlatformError from "effect/PlatformError"
7
9
 
8
10
  /**
9
11
  * Generate a cryptographically secure UUID v4.
10
- * Uses Web Crypto API randomUUID (Node.js 19+, all modern browsers).
12
+ * Uses Effect's platform Crypto service.
11
13
  *
12
14
  * @category Utilities
13
15
  */
14
- export const generateUUID = (): Effect.Effect<string> => Effect.sync(() => globalThis.crypto.randomUUID())
16
+ export const generateUUID = (): Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto> =>
17
+ Effect.gen(function*() {
18
+ const cryptoService = yield* Crypto.Crypto
19
+ return yield* cryptoService.randomUUIDv4
20
+ })
package/src/bin.ts CHANGED
@@ -112,4 +112,4 @@ const program = Effect.gen(function*() {
112
112
  Effect.catch((error: unknown) => Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error))))
113
113
  )
114
114
 
115
- NodeRuntime.runMain(program)
115
+ NodeRuntime.runMain(program, { disableErrorReporting: true })
@@ -73,7 +73,7 @@ const emptyProfiles = (): AuthProfilesFile => ({ profiles: [] })
73
73
 
74
74
  const parseJsonOrNull = (content: string): unknown | null => {
75
75
  try {
76
- return JSON.parse(content) as unknown
76
+ return JSON.parse(content)
77
77
  } catch {
78
78
  return null
79
79
  }
@@ -12,7 +12,14 @@ import * as Schema from "effect/Schema"
12
12
  import type { HttpClient } from "effect/unstable/http"
13
13
  import type { OAuthError } from "../auth/OAuthErrors.js"
14
14
  import { refreshToken } from "../auth/OAuthOperations.js"
15
- import { type AuthProfile, findProfile, loadProfiles, saveProfiles, saveProfileToken } from "./AuthProfiles.js"
15
+ import {
16
+ type AuthProfile,
17
+ type AuthProfilesFile,
18
+ findProfile,
19
+ loadProfiles,
20
+ saveProfiles,
21
+ saveProfileToken
22
+ } from "./AuthProfiles.js"
16
23
  import { getProfilesPath, type HomeDirectoryError, HomeDirectoryTag } from "./ConfigPaths.js"
17
24
  import { type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
18
25
  import { FileSystemError, isTokenExpired, loadOAuthConfig } from "./TokenStorage.js"
@@ -26,24 +33,24 @@ export interface AtlassianToolDefinition {
26
33
  readonly requiredScopes: ReadonlyArray<string>
27
34
  }
28
35
 
29
- export const JIRA_REQUIRED_SCOPES = [
36
+ export const JIRA_REQUIRED_SCOPES: ReadonlyArray<string> = [
30
37
  "read:jira-work",
31
38
  "write:jira-work",
32
39
  "manage:jira-project",
33
40
  "read:jira-user",
34
41
  "read:me",
35
42
  "offline_access"
36
- ] as const
43
+ ]
37
44
 
38
- export const CONFLUENCE_REQUIRED_SCOPES = [
45
+ export const CONFLUENCE_REQUIRED_SCOPES: ReadonlyArray<string> = [
39
46
  "read:page:confluence",
40
47
  "write:page:confluence",
41
48
  "delete:page:confluence",
42
49
  "read:me",
43
50
  "offline_access"
44
- ] as const
51
+ ]
45
52
 
46
- export const ATLASSIAN_TOOLS = [
53
+ export const ATLASSIAN_TOOLS: ReadonlyArray<AtlassianToolDefinition> = [
47
54
  {
48
55
  toolName: "jira-cli",
49
56
  label: "Jira CLI",
@@ -64,7 +71,7 @@ export const ATLASSIAN_TOOLS = [
64
71
  loginHint: "jcf auth jira login",
65
72
  requiredScopes: JIRA_REQUIRED_SCOPES
66
73
  }
67
- ] as const satisfies ReadonlyArray<AtlassianToolDefinition>
74
+ ]
68
75
 
69
76
  export type ProfileTokenStatus = "valid" | "expired"
70
77
 
@@ -97,6 +104,11 @@ export interface ToolProfileStatus {
97
104
 
98
105
  const authStoreName = (tool: AtlassianToolDefinition): string => tool.authStoreName ?? tool.toolName
99
106
 
107
+ const toolStorePair = (
108
+ tool: AtlassianToolDefinition,
109
+ store: AuthProfilesFile
110
+ ): readonly [AtlassianToolDefinition, AuthProfilesFile] => [tool, store]
111
+
100
112
  const uniqueAuthTools = (tools: ReadonlyArray<AtlassianToolDefinition>): ReadonlyArray<AtlassianToolDefinition> =>
101
113
  tools.filter((tool, index) =>
102
114
  tools.findIndex((candidate) => authStoreName(candidate) === authStoreName(tool)) === index
@@ -122,7 +134,7 @@ const loadLegacyToken = (
122
134
  Effect.mapError((cause) => new FileSystemError({ operation: "read", path: authPath, cause }))
123
135
  )
124
136
  const parsed = yield* Effect.try({
125
- try: () => JSON.parse(content) as unknown,
137
+ try: () => JSON.parse(content),
126
138
  catch: (cause) => cause
127
139
  }).pipe(Effect.catch(() => Effect.succeed(null)))
128
140
  if (parsed === null) return null
@@ -186,7 +198,7 @@ export const useProfileForAllTools = (
186
198
  Effect.gen(function*() {
187
199
  const authTools = uniqueAuthTools(tools)
188
200
  const stores = yield* Effect.forEach(authTools, (tool) =>
189
- loadProfiles(authStoreName(tool)).pipe(Effect.map((store) => [tool, store] as const)))
201
+ loadProfiles(authStoreName(tool)).pipe(Effect.map((store) => toolStorePair(tool, store))))
190
202
  const selected = stores.map(([, store]) =>
191
203
  findProfile(store.profiles, selector)
192
204
  ).find((profile): profile is AuthProfile =>
@@ -29,7 +29,7 @@ import { type OAuthConfig, OAuthConfigSchema, type OAuthToken, OAuthTokenSchema
29
29
 
30
30
  const parseJsonOrNull = (content: string): unknown | null => {
31
31
  try {
32
- return JSON.parse(content) as unknown
32
+ return JSON.parse(content)
33
33
  } catch {
34
34
  return null
35
35
  }
package/src/index.ts CHANGED
@@ -32,3 +32,6 @@ export * from "./config/index.js"
32
32
 
33
33
  // Auth utilities
34
34
  export * from "./auth/index.js"
35
+
36
+ // Attachment utilities
37
+ export * from "./attachments.js"
@@ -22,8 +22,10 @@
22
22
  * @module
23
23
  */
24
24
  import * as Effect from "effect/Effect"
25
+ import type { BlockNode, BlockQuote, List } from "../ast/BlockNode.js"
25
26
  import type { Document, DocumentNode } from "../ast/Document.js"
26
27
  import type { InlineNode } from "../ast/InlineNode.js"
28
+ import type { ExpandMacro, InfoPanel } from "../ast/MacroNode.js"
27
29
  import type { SerializeError } from "../SerializeError.js"
28
30
 
29
31
  /**
@@ -242,28 +244,18 @@ const serializeTable = (
242
244
  return lines.join("\n")
243
245
  })
244
246
 
245
- // Simple block type for list items - use unknown for flexibility
246
- type SimpleBlock = unknown
247
-
248
- // List item type
249
- type ListItemType = {
250
- readonly _tag: "ListItem"
251
- readonly checked?: boolean | undefined
252
- readonly children: ReadonlyArray<unknown>
253
- }
254
-
255
247
  /**
256
248
  * Serialize list.
257
249
  */
258
250
  const serializeList = (
259
- node: { ordered: boolean; start?: number | undefined; children: ReadonlyArray<unknown> },
251
+ node: List,
260
252
  options: SerializeOptions
261
253
  ): Effect.Effect<string, SerializeError> =>
262
254
  Effect.gen(function*() {
263
255
  const lines: Array<string> = []
264
256
  let counter = node.start ?? 1
265
257
 
266
- for (const item of node.children as ReadonlyArray<ListItemType>) {
258
+ for (const item of node.children) {
267
259
  const prefix = node.ordered ? `${counter}.` : "-"
268
260
  const checkbox = item.checked !== undefined ? (item.checked ? "[x] " : "[ ] ") : ""
269
261
 
@@ -291,38 +283,31 @@ const serializeList = (
291
283
  * Serialize simple block (for nested content).
292
284
  */
293
285
  const serializeSimpleBlock = (
294
- node: SimpleBlock,
286
+ node: BlockNode,
295
287
  options: SerializeOptions
296
288
  ): Effect.Effect<string, SerializeError> =>
297
289
  Effect.gen(function*() {
298
- const tagged = node as { readonly _tag: string }
299
- switch (tagged._tag) {
290
+ switch (node._tag) {
300
291
  case "Heading":
301
- return yield* serializeHeading(
302
- node as unknown as { level: 1 | 2 | 3 | 4 | 5 | 6; children: ReadonlyArray<InlineNode> }
303
- )
292
+ return yield* serializeHeading(node)
304
293
  case "Paragraph":
305
- return yield* serializeParagraph(node as unknown as { children: ReadonlyArray<InlineNode> })
294
+ return yield* serializeParagraph(node)
306
295
  case "CodeBlock":
307
- return serializeCodeBlock(node as unknown as { code: string; language?: string | undefined })
296
+ return serializeCodeBlock(node)
308
297
  case "ThematicBreak":
309
298
  return "---"
310
299
  case "Image":
311
- return serializeImage(
312
- node as unknown as { src: string; alt?: string | undefined; title?: string | undefined },
313
- options
314
- )
300
+ return serializeImage(node, options)
315
301
  case "Table":
316
- return yield* serializeTable(
317
- node as unknown as {
318
- header?: { cells: ReadonlyArray<{ children: ReadonlyArray<InlineNode> }> } | undefined
319
- rows: ReadonlyArray<{ cells: ReadonlyArray<{ children: ReadonlyArray<InlineNode> }> }>
320
- }
321
- )
322
- case "UnsupportedBlock": {
323
- const unsupported = node as unknown as { rawMarkdown?: string; rawHtml?: string; rawAdf?: string }
324
- return unsupported.rawMarkdown ?? unsupported.rawHtml ?? unsupported.rawAdf ?? ""
325
- }
302
+ return yield* serializeTable(node)
303
+ case "List":
304
+ return yield* serializeList(node, options)
305
+ case "BlockQuote":
306
+ return yield* serializeBlockQuote(node, options)
307
+ case "TaskList":
308
+ return yield* serializeTaskList(node.children, options)
309
+ case "UnsupportedBlock":
310
+ return node.rawMarkdown ?? node.rawHtml ?? node.rawAdf ?? ""
326
311
  default:
327
312
  return ""
328
313
  }
@@ -332,7 +317,7 @@ const serializeSimpleBlock = (
332
317
  * Serialize block quote.
333
318
  */
334
319
  const serializeBlockQuote = (
335
- node: { children: ReadonlyArray<unknown> },
320
+ node: BlockQuote,
336
321
  options: SerializeOptions
337
322
  ): Effect.Effect<string, SerializeError> =>
338
323
  Effect.gen(function*() {
@@ -349,7 +334,7 @@ const serializeBlockQuote = (
349
334
  * Serialize info panel to container syntax.
350
335
  */
351
336
  const serializeInfoPanel = (
352
- node: { panelType: string; title?: string | undefined; children: ReadonlyArray<unknown> },
337
+ node: InfoPanel,
353
338
  options: SerializeOptions
354
339
  ): Effect.Effect<string, SerializeError> =>
355
340
  Effect.gen(function*() {
@@ -370,7 +355,7 @@ const serializeInfoPanel = (
370
355
  * Serialize expand macro.
371
356
  */
372
357
  const serializeExpandMacro = (
373
- node: { title?: string | undefined; children: ReadonlyArray<unknown> },
358
+ node: ExpandMacro,
374
359
  options: SerializeOptions
375
360
  ): Effect.Effect<string, SerializeError> =>
376
361
  Effect.gen(function*() {
@@ -57,7 +57,7 @@ const makeMockFS = (files: Record<string, string> = {}) => {
57
57
 
58
58
  return {
59
59
  store,
60
- layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial as FileSystem.FileSystem))
60
+ layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial))
61
61
  }
62
62
  }
63
63
 
@@ -1,25 +1,43 @@
1
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
1
2
  import { it as effectIt } from "@effect/vitest"
2
3
  import * as Effect from "effect/Effect"
3
4
  import { describe, expect, it } from "vitest"
4
- import { AUTH_URL, buildAuthUrl, computeCodeChallenge, generateCodeVerifier } from "../src/auth/OAuthEndpoints.js"
5
+ import {
6
+ AUTH_URL,
7
+ buildAuthUrl,
8
+ computeCodeChallenge,
9
+ CONFLUENCE_SCOPES,
10
+ generateCodeVerifier
11
+ } from "../src/auth/OAuthEndpoints.js"
12
+
13
+ const withCrypto = <A, E, R>(effect: Effect.Effect<A, E, R>) => effect.pipe(Effect.provide(NodeCrypto.layer))
5
14
 
6
15
  describe("OAuthEndpoints", () => {
16
+ describe("CONFLUENCE_SCOPES", () => {
17
+ it("includes attachment read and write scopes", () => {
18
+ expect(CONFLUENCE_SCOPES).toContain("read:attachment:confluence")
19
+ expect(CONFLUENCE_SCOPES).toContain("write:attachment:confluence")
20
+ })
21
+ })
22
+
7
23
  // ---- PKCE ----
8
24
 
9
25
  describe("generateCodeVerifier", () => {
10
26
  // 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
- })
27
+ effectIt.effect("returns a base64url string of 43 characters (32 random bytes)", () =>
28
+ Effect.gen(function*() {
29
+ const verifier = yield* generateCodeVerifier()
30
+ expect(verifier).toHaveLength(43)
31
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
32
+ }).pipe(withCrypto))
16
33
 
17
34
  // 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
- })
35
+ effectIt.effect("produces unique values", () =>
36
+ Effect.gen(function*() {
37
+ const a = yield* generateCodeVerifier()
38
+ const b = yield* generateCodeVerifier()
39
+ expect(a).not.toBe(b)
40
+ }).pipe(withCrypto))
23
41
  })
24
42
 
25
43
  describe("computeCodeChallenge", () => {
@@ -30,23 +48,23 @@ describe("OAuthEndpoints", () => {
30
48
  const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
31
49
  const challenge = yield* computeCodeChallenge(verifier)
32
50
  expect(challenge).toBe(expected)
33
- }))
51
+ }).pipe(withCrypto))
34
52
 
35
53
  // Same verifier must always produce same challenge — server verifies this on token exchange
36
54
  effectIt.effect("is deterministic", () =>
37
55
  Effect.gen(function*() {
38
- const v = generateCodeVerifier()
56
+ const v = yield* generateCodeVerifier()
39
57
  const a = yield* computeCodeChallenge(v)
40
58
  const b = yield* computeCodeChallenge(v)
41
59
  expect(a).toBe(b)
42
- }))
60
+ }).pipe(withCrypto))
43
61
 
44
62
  // base64url must not contain +, /, = — these break URL query params
45
63
  effectIt.effect("produces base64url output (no +, /, =)", () =>
46
64
  Effect.gen(function*() {
47
65
  const challenge = yield* computeCodeChallenge("test-verifier-string")
48
66
  expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/)
49
- }))
67
+ }).pipe(withCrypto))
50
68
  })
51
69
 
52
70
  // ---- buildAuthUrl ----
@@ -103,7 +121,7 @@ describe("OAuthEndpoints", () => {
103
121
  const parsed = new URL(url)
104
122
  expect(parsed.searchParams.get("code_challenge")).toBe(challenge)
105
123
  expect(parsed.searchParams.get("code_challenge_method")).toBe("S256")
106
- })
124
+ }).pipe(withCrypto)
107
125
  )
108
126
  })
109
127
  })
@@ -1,6 +1,7 @@
1
1
  import { describe, it } from "@effect/vitest"
2
2
  import * as Effect from "effect/Effect"
3
3
  import * as Layer from "effect/Layer"
4
+ import { TestClock } from "effect/testing"
4
5
  import type { HttpClientRequest } from "effect/unstable/http"
5
6
  import { HttpClient, HttpClientResponse } from "effect/unstable/http"
6
7
  import { expect } from "vitest"
@@ -58,6 +59,8 @@ const oauthConfig: OAuthConfig = {
58
59
  clientSecret: "csecret"
59
60
  }
60
61
 
62
+ const TEST_NOW = Date.parse("2026-01-01T00:00:00.000Z")
63
+
61
64
  const oauthToken: OAuthToken = {
62
65
  access_token: "old-access",
63
66
  refresh_token: "old-refresh",
@@ -221,6 +224,7 @@ describe("OAuthOperations", () => {
221
224
  // Verifies grant_type=refresh_token is sent and returned token preserves cloud_id/site_url from original
222
225
  it.effect("sends refresh_token and returns updated OAuthToken", () =>
223
226
  Effect.gen(function*() {
227
+ yield* TestClock.setTime(TEST_NOW)
224
228
  const { capturedRequests, mockClient } = createMockHttpClient([
225
229
  { status: 200, body: { ...validTokenBody, access_token: "new-access", refresh_token: "new-refresh" } }
226
230
  ])
@@ -232,7 +236,7 @@ describe("OAuthOperations", () => {
232
236
  // preserves existing fields
233
237
  expect(result.cloud_id).toBe("cloud-1")
234
238
  expect(result.site_url).toBe("https://site.atlassian.net")
235
- expect(result.expires_at).toBeGreaterThan(Date.now())
239
+ expect(result.expires_at).toBe(TEST_NOW + validTokenBody.expires_in * 1000)
236
240
 
237
241
  const req = capturedRequests[0]!
238
242
  const body = yield* req.body._tag === "Uint8Array"
@@ -64,7 +64,7 @@ const makeMockFS = () => {
64
64
  }
65
65
  return {
66
66
  store,
67
- layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial as FileSystem.FileSystem))
67
+ layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial))
68
68
  }
69
69
  }
70
70