@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.
- package/CHANGELOG.md +23 -0
- package/README.md +4 -3
- package/dist/Brand.d.ts.map +1 -1
- package/dist/Brand.js +4 -2
- package/dist/Brand.js.map +1 -1
- package/dist/Hash.d.ts +4 -4
- package/dist/Hash.js +6 -6
- package/dist/Hash.js.map +1 -1
- package/dist/ast/Document.d.ts +2 -2
- package/dist/ast/Document.d.ts.map +1 -1
- package/dist/ast/Document.js +1 -1
- package/dist/ast/Document.js.map +1 -1
- package/dist/ast/MacroNode.d.ts +4 -4
- package/dist/ast/MacroNode.d.ts.map +1 -1
- package/dist/ast/MacroNode.js +8 -1
- package/dist/ast/MacroNode.js.map +1 -1
- package/dist/attachments.d.ts +42 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +51 -0
- package/dist/attachments.js.map +1 -0
- package/dist/auth/OAuthEndpoints.d.ts +6 -4
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
- package/dist/auth/OAuthEndpoints.js +14 -7
- package/dist/auth/OAuthEndpoints.js.map +1 -1
- package/dist/auth/OAuthErrors.d.ts.map +1 -1
- package/dist/auth/OAuthErrors.js +4 -3
- package/dist/auth/OAuthErrors.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +0 -20
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +3 -1
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/auth/uuid.d.ts +5 -3
- package/dist/auth/uuid.d.ts.map +1 -1
- package/dist/auth/uuid.js +7 -3
- package/dist/auth/uuid.js.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/config/AuthProfiles.js.map +1 -1
- package/dist/config/ProfileManager.d.ts +3 -20
- package/dist/config/ProfileManager.d.ts.map +1 -1
- package/dist/config/ProfileManager.js +2 -1
- package/dist/config/ProfileManager.js.map +1 -1
- package/dist/config/TokenStorage.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/serializers/MarkdownSerializer.d.ts.map +1 -1
- package/dist/serializers/MarkdownSerializer.js +9 -6
- package/dist/serializers/MarkdownSerializer.js.map +1 -1
- package/package.json +12 -8
- package/src/Brand.ts +4 -2
- package/src/Hash.ts +6 -6
- package/src/ast/Document.ts +1 -1
- package/src/ast/MacroNode.ts +8 -1
- package/src/attachments.ts +98 -0
- package/src/auth/OAuthEndpoints.ts +20 -11
- package/src/auth/OAuthErrors.ts +4 -3
- package/src/auth/OAuthOperations.ts +3 -1
- package/src/auth/uuid.ts +9 -3
- package/src/bin.ts +1 -1
- package/src/config/AuthProfiles.ts +1 -1
- package/src/config/ProfileManager.ts +21 -9
- package/src/config/TokenStorage.ts +1 -1
- package/src/index.ts +3 -0
- package/src/serializers/MarkdownSerializer.ts +22 -37
- package/test/AuthProfiles.test.ts +1 -1
- package/test/OAuthEndpoints.test.ts +34 -16
- package/test/OAuthOperations.test.ts +5 -1
- package/test/ProfileManager.test.ts +1 -1
- package/test/attachments.test.ts +75 -0
- package/test/fixtures/attachments/inline-attachment.svg +6 -0
- 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
|
+
? ``
|
|
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
|
|
127
|
+
* Uses Effect's platform Crypto service.
|
|
124
128
|
*
|
|
125
129
|
* @category PKCE
|
|
126
130
|
*/
|
|
127
|
-
export const generateCodeVerifier = (): string =>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
140
|
+
* Uses Effect's platform Crypto service.
|
|
136
141
|
*
|
|
137
142
|
* @category PKCE
|
|
138
143
|
*/
|
|
139
|
-
export const computeCodeChallenge = (
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
})
|
package/src/auth/OAuthErrors.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
12
|
+
* Uses Effect's platform Crypto service.
|
|
11
13
|
*
|
|
12
14
|
* @category Utilities
|
|
13
15
|
*/
|
|
14
|
-
export const generateUUID = (): Effect.Effect<string
|
|
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
|
@@ -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 {
|
|
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
|
-
]
|
|
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
|
-
]
|
|
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
|
-
]
|
|
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)
|
|
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) =>
|
|
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)
|
|
32
|
+
return JSON.parse(content)
|
|
33
33
|
} catch {
|
|
34
34
|
return null
|
|
35
35
|
}
|
package/src/index.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
286
|
+
node: BlockNode,
|
|
295
287
|
options: SerializeOptions
|
|
296
288
|
): Effect.Effect<string, SerializeError> =>
|
|
297
289
|
Effect.gen(function*() {
|
|
298
|
-
|
|
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
|
|
294
|
+
return yield* serializeParagraph(node)
|
|
306
295
|
case "CodeBlock":
|
|
307
|
-
return serializeCodeBlock(node
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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).
|
|
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"
|