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 /** * Call a LinkedIn API endpoint on `api.linkedin.com` using the saved OAuth token. Prefer this escape hatch when a dedicated helper does not exist. * * @example * import mod from 'kody:@kody/linkedin/request' * const result = await mod() */ 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 { 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 body?: unknown restli?: boolean linkedinVersion?: string } = {}, ): Promise { 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 { 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 body?: unknown restli?: boolean linkedinVersion?: string } = {}, ): Promise { 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( path: string, options: { method?: string query?: QueryInput headers?: Record body?: unknown restli?: boolean linkedinVersion?: string } = {}, ): Promise { 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 { 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 { return { 'linkedin-version': LINKEDIN_VERSION, 'x-restli-protocol-version': RESTLI_PROTOCOL_VERSION, } } export async function parseResponseBody(response: Response): Promise { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return text } } function selectResponseHeaders(headers: Headers): Record { const selected = ['x-restli-id', 'location', 'content-type', 'linkedin-version'] const output: Record = {} for (const key of selected) { const value = headers.get(key) if (value) output[key] = value } return output } function redactAuthHeader(headers: Record = {}): Record { 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 & Record = {}, ) { const input = requireRecord(params, 'request') requireStringField(input, 'path', 'request') return request(input as LinkedInRequestInput) }