import { createAuthenticatedFetch } from 'kody:runtime'
import type {
JsonRecord,
LinkedInRequestInput,
LinkedInRequestResult,
QueryInput,
} from './types.ts'
import { requireRecord, requireStringField } from './types.ts'
export const LINKEDIN_API_ORIGIN = 'https://api.linkedin.com'
export const LINKEDIN_UPLOAD_ORIGIN = 'https://www.linkedin.com'
export const LINKEDIN_INTEGRATION = 'linkedin'
export const LINKEDIN_ACCESS_TOKEN_SECRET = 'linkedinAccessToken'
export const LINKEDIN_VERSION = '202602'
export const RESTLI_PROTOCOL_VERSION = '2.0.0'
export const LINKEDIN_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const LINKEDIN_CONNECT_URL = 'https://kody.codes/connect/oauth?provider=linkedin'
export const LINKEDIN_AUTHORIZE_URL = 'https://www.linkedin.com/oauth/v2/authorization'
export const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'
export const LINKEDIN_DASHBOARD_URL = 'https://www.linkedin.com/developers/apps'
export const LINKEDIN_SCOPES = ['openid', 'profile', 'email', 'w_member_social'] as const
export function byoConnectUrl(): string {
const params = new URLSearchParams({
provider: LINKEDIN_INTEGRATION,
authorizeUrl: LINKEDIN_AUTHORIZE_URL,
tokenUrl: LINKEDIN_TOKEN_URL,
apiBaseUrl: LINKEDIN_API_ORIGIN,
flow: 'confidential',
tokenExchangeStyle: 'form',
allowedHosts: 'api.linkedin.com,www.linkedin.com',
dashboardUrl: LINKEDIN_DASHBOARD_URL,
scopes: LINKEDIN_SCOPES.join(' '),
})
return LINKEDIN_CALLBACK_URL + '?' + params.toString()
}
export class LinkedInApiError extends Error {
readonly status: number
readonly statusText: string
readonly details: unknown
constructor(message: string, options: { status: number; statusText: string; details: unknown }) {
super(message)
this.name = 'LinkedInApiError'
this.status = options.status
this.statusText = options.statusText
this.details = options.details
}
}
let __authedFetch: typeof fetch | null = null
export async function getLinkedInAuthenticatedFetch(): Promise<typeof fetch> {
if (!__authedFetch) {
__authedFetch = await createAuthenticatedFetch(LINKEDIN_INTEGRATION)
}
return __authedFetch
}
/**
* Compact authenticated LinkedIn API request helper.
* Injects OAuth via createAuthenticatedFetch('linkedin') plus LinkedIn-Version /
* X-Restli-Protocol-Version when restli is requested. Returns raw Response.
*/
export async function rawLinkedInRequest(
path: string,
options: {
method?: string
query?: QueryInput
headers?: Record<string, string>
body?: unknown
restli?: boolean
linkedinVersion?: string
} = {},
): Promise<Response> {
const url = buildLinkedInUrl(path, options.query)
const headers = new Headers(options.headers)
if (!headers.has('accept')) headers.set('accept', 'application/json')
if (options.restli === true && !headers.has('x-restli-protocol-version')) {
headers.set('x-restli-protocol-version', RESTLI_PROTOCOL_VERSION)
}
if (options.linkedinVersion && !headers.has('linkedin-version')) {
headers.set('linkedin-version', options.linkedinVersion)
}
const method = (options.method || 'GET').toUpperCase()
const init: RequestInit = { method, headers }
if (options.body !== undefined && options.body !== null) {
if (typeof options.body === 'string') {
init.body = options.body
} else if (options.body instanceof URLSearchParams || options.body instanceof ArrayBuffer) {
init.body = options.body
} else {
if (!headers.has('content-type')) headers.set('content-type', 'application/json')
init.body = JSON.stringify(options.body)
}
}
const authedFetch = await getLinkedInAuthenticatedFetch()
return authedFetch(url, init)
}
export async function uploadLinkedInImageBytes(
uploadUrl: string,
bytes: ArrayBuffer,
contentType: string,
): Promise<LinkedInRequestResult> {
const url = new URL(uploadUrl)
if (url.origin !== LINKEDIN_UPLOAD_ORIGIN) {
throw new Error('LinkedIn image upload URLs must use ' + LINKEDIN_UPLOAD_ORIGIN + '.')
}
const authedFetch = await getLinkedInAuthenticatedFetch()
const response = await authedFetch(url, {
method: 'PUT',
headers: {
'content-type': contentType,
},
body: bytes,
})
const parsedBody = await parseResponseBody(response)
if (!response.ok) {
throw new LinkedInApiError(
'LinkedIn image upload failed: ' + response.status + ' ' + response.statusText,
{
status: response.status,
statusText: response.statusText,
details: parsedBody,
},
)
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: selectResponseHeaders(response.headers),
body: parsedBody,
}
}
export async function linkedinRequestWithResponse(
path: string,
options: {
method?: string
query?: QueryInput
headers?: Record<string, string>
body?: unknown
restli?: boolean
linkedinVersion?: string
} = {},
): Promise<LinkedInRequestResult> {
const response = await rawLinkedInRequest(path, options)
const parsedBody = await parseResponseBody(response)
if (!response.ok) {
throw new LinkedInApiError(
'LinkedIn API request failed: ' + response.status + ' ' + response.statusText,
{
status: response.status,
statusText: response.statusText,
details: parsedBody,
},
)
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: selectResponseHeaders(response.headers),
body: parsedBody,
}
}
export async function linkedinRequest<T>(
path: string,
options: {
method?: string
query?: QueryInput
headers?: Record<string, string>
body?: unknown
restli?: boolean
linkedinVersion?: string
} = {},
): Promise<T> {
const result = await linkedinRequestWithResponse(path, options)
return result.body as T
}
/** Public request helper with dry-run / confirm guards for non-GET calls. */
export async function request(
input: LinkedInRequestInput,
): Promise<LinkedInRequestResult | JsonRecord> {
const method = (input.method || 'GET').toUpperCase()
const url = buildLinkedInUrl(input.path, input.query)
const body = input.body ?? null
if (input.dryRun) {
return {
dryRun: true,
method,
url,
body,
headers: redactAuthHeader(input.headers),
}
}
if (method !== 'GET' && method !== 'HEAD' && input.confirm !== true) {
throw new Error(
'request performs a non-read LinkedIn API call. Pass params.confirm = true only after explicit user approval.',
)
}
return linkedinRequestWithResponse(input.path, {
method,
query: input.query,
headers: input.headers,
body: input.body,
})
}
export function buildLinkedInUrl(path: string, query: QueryInput = {}): string {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('LinkedIn request path is required.')
}
const url = path.startsWith('https://')
? new URL(path)
: new URL(LINKEDIN_API_ORIGIN + (path.startsWith('/') ? path : '/' + path))
if (url.origin !== LINKEDIN_API_ORIGIN) {
throw new Error('LinkedIn request URLs must use https://api.linkedin.com.')
}
for (const [key, value] of Object.entries(query)) {
const values = Array.isArray(value) ? value : [value]
for (const item of values) {
if (item !== null && item !== undefined && item !== '') {
url.searchParams.append(key, String(item))
}
}
}
return url.toString()
}
export function linkedInRestHeadersPreview(): Record<string, string> {
return {
'linkedin-version': LINKEDIN_VERSION,
'x-restli-protocol-version': RESTLI_PROTOCOL_VERSION,
}
}
export async function parseResponseBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
function selectResponseHeaders(headers: Headers): Record<string, string> {
const selected = ['x-restli-id', 'location', 'content-type', 'linkedin-version']
const output: Record<string, string> = {}
for (const key of selected) {
const value = headers.get(key)
if (value) output[key] = value
}
return output
}
function redactAuthHeader(headers: Record<string, string> = {}): Record<string, string> {
return {
...headers,
authorization: '[managed linkedin OAuth integration]',
}
}
/**
* Call a LinkedIn API endpoint on `api.linkedin.com` using the saved OAuth token.
* @param params.path - Relative API path such as `/v2/userinfo`.
* @returns Parsed JSON body and response metadata.
* @example
* import request from 'kody:@kody/linkedin/request'
* const result = await request({ path: '/v2/userinfo' })
* // => { status: 200, data: { sub: '...', ... } }
*/
export default async function requestEntrypoint(
params: Partial<LinkedInRequestInput> & Record<string, unknown> = {},
) {
const input = requireRecord(params, 'request')
requireStringField(input, 'path', 'request')
return request(input as LinkedInRequestInput)
}