import type { ProductHuntAuthInput } from './types.ts'
import { optionalString, requireString } from './types.ts'
export const PRODUCTHUNT_GRAPHQL_URL = 'https://api.producthunt.com/v2/api/graphql'
export const PRODUCTHUNT_AUTHORIZE_URL = 'https://api.producthunt.com/v2/oauth/authorize'
export const PRODUCTHUNT_TOKEN_URL = 'https://api.producthunt.com/v2/oauth/token'
export const PRODUCTHUNT_APPLICATIONS_URL = 'https://api.producthunt.com/v2/oauth/applications'
export const PRODUCTHUNT_REQUIRED_HOST = 'api.producthunt.com'
export const PRODUCTHUNT_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const DEFAULT_INTEGRATION_NAME = 'producthunt'
export const DEFAULT_ACCESS_TOKEN_SECRET = 'producthuntAccessToken'
export const DEFAULT_CLIENT_SECRET_SECRET = 'producthuntClientSecret'
export const DEFAULT_OAUTH_SCOPES = ['public', 'private'] as const
export const PRODUCTHUNT_DAY_TIMEZONE = 'America/Los_Angeles'
export const PRODUCTHUNT_SCOPES = {
public: 'Read published posts, hunters, topics, collections, and comments.',
private: 'Read viewer.user for the connected hunter.',
write: 'Goal and follow mutations. Product Hunt must approve the app first.',
} as const
export type ProductHuntScope = keyof typeof PRODUCTHUNT_SCOPES
export type ProductHuntOperation = 'query' | 'mutation'
export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
const name = requireString(provider, 'provider')
const params = new URLSearchParams({
provider: name,
authorizeUrl: PRODUCTHUNT_AUTHORIZE_URL,
tokenUrl: PRODUCTHUNT_TOKEN_URL,
apiBaseUrl: PRODUCTHUNT_GRAPHQL_URL,
flow: 'confidential',
allowedHosts: PRODUCTHUNT_REQUIRED_HOST,
dashboardUrl: PRODUCTHUNT_APPLICATIONS_URL,
scopes: DEFAULT_OAUTH_SCOPES.join(' '),
})
return `${PRODUCTHUNT_CALLBACK_URL}?${params.toString()}`
}
export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
return `${PRODUCTHUNT_CALLBACK_URL}?provider=${encodeURIComponent(requireString(provider, 'provider'))}`
}
export function accessTokenSetupUrl(secretName: string = DEFAULT_ACCESS_TOKEN_SECRET): string {
const name = requireString(secretName, 'secretName')
const params = new URLSearchParams({
name,
description: 'Product Hunt developer or OAuth access token for GraphQL reads',
allowedHosts: PRODUCTHUNT_REQUIRED_HOST,
scope: 'user',
})
return `https://kody.codes/account/secrets/new?${params.toString()}`
}
export function clientSecretSetupUrl(secretName: string = DEFAULT_CLIENT_SECRET_SECRET): string {
const name = requireString(secretName, 'secretName')
const params = new URLSearchParams({
name,
description: 'Product Hunt OAuth client secret for the confidential app',
allowedHosts: PRODUCTHUNT_REQUIRED_HOST,
scope: 'user',
})
return `https://kody.codes/account/secrets/new?${params.toString()}`
}
export function resolveIntegrationName(input: ProductHuntAuthInput = {}): string {
const explicit =
optionalString(input.integrationName, 'integrationName') ??
optionalString(input.integration, 'integration')
if (explicit) return explicit
const account = optionalString(input.account, 'account')
if (!account || account === 'default' || account === 'producthunt') {
return DEFAULT_INTEGRATION_NAME
}
if (account.startsWith('producthunt-')) return account
return `producthunt-${account}`
}
export function resolveSecretName(input: ProductHuntAuthInput = {}): string {
const explicit = optionalString(input.secretName, 'secretName')
if (explicit) return explicit
const integrationName = resolveIntegrationName(input)
if (integrationName === DEFAULT_INTEGRATION_NAME) return DEFAULT_ACCESS_TOKEN_SECRET
const suffix = integrationName.startsWith('producthunt-')
? integrationName.slice('producthunt-'.length)
: integrationName
return `${DEFAULT_ACCESS_TOKEN_SECRET}-${suffix}`
}
/** Clamp a Product Hunt page size to the API's 1–20 range. */
export function clampPageSize(first?: number, fallback = 10) {
const value = first ?? fallback
if (!Number.isFinite(value)) return fallback
return Math.min(20, Math.max(1, Math.trunc(value)))
}
export function inferOperation(query: string): ProductHuntOperation {
return /\bmutation\b/i.test(query) ? 'mutation' : 'query'
}
export function isReadOnlyQuery(query: string): boolean {
return inferOperation(query) === 'query'
}
export function scopeForOperation(operation: ProductHuntOperation): ProductHuntScope {
switch (operation) {
case 'query':
return 'public'
case 'mutation':
return 'write'
default: {
const exhaustive: never = operation
throw new Error(`Unsupported Product Hunt operation: ${String(exhaustive)}`)
}
}
}
export function missingCredentialsMessage(options: {
integrationName: string
secretName: string
}): string {
return [
'Product Hunt credentials are missing.',
'OAuth (recommended): create a Confidential app at',
PRODUCTHUNT_APPLICATIONS_URL,
`with redirect URI ${PRODUCTHUNT_CALLBACK_URL}, then connect:`,
oauthConnectUrl(options.integrationName),
'Developer token (public reads): save it (do not paste the value in chat):',
accessTokenSetupUrl(options.secretName),
`Required API host: ${PRODUCTHUNT_REQUIRED_HOST}.`,
].join(' ')
}