/** * PeerTube connector — federated video platform, one instance per connection. * * Auth: per-instance OAuth2 with the password grant (facts verified against * PeerTube v8.3 — docs/research/peertube-api-facts.md): * - Every instance exposes its own local OAuth client at * GET /api/v1/oauth-clients/local (client_id + client_secret). There is no * RFC 7591 registration endpoint; fetching the local client IS the * per-instance "registration". * - POST /api/v1/users/token with grant_type=password trades * username + password for an access token (~1 day) and a refresh token * (~2 weeks). 2FA rides on the x-peertube-otp header. * - The password is used for that single exchange and is NEVER persisted: * only access_token, refresh_token and the instance client credentials go * into the CredentialAccessor; the returned connection carries * instanceUrl + username only. Afterwards every call runs on the stored * tokens, and a 401 triggers one refresh_token round-trip and a retry. * - The SDK forbids connection fields named "password" (so generic UIs and * logs never treat it as a stored account password); the one-time field is * therefore named `accountSecret`. * - No granular scopes exist — authorization follows the account's role. * * Honest capability notes: * - No text posts, feed or DMs — PeerTube is video-only, with comments. * - Lives: POST /api/v1/videos/live, then GET /api/v1/videos/live/{id} for * rtmpUrl + streamKey (owner-only). The stream key goes straight into the * CredentialAccessor; LiveStream carries a credential-ref, never the key. * saveReplay + permanentLive is rejected by PeerTube (400), so we refuse it * before any request. * - Live state: there is no LIVE VideoState. A running live is * PUBLISHED (1) with isLive=true; WAITING_FOR_LIVE=4, LIVE_ENDED=5. * - Live chat: PeerTube core has none. Chat exists only through the * third-party livechat plugin (XMPP, instance-dependent), so liveChatRead / * liveChatWrite are not declared and getLiveChat is intentionally absent. * Comments on the live video are reachable through getComments. * - Webhooks: PeerTube has no outbound webhooks (plugin action hooks only), * so `webhooks` is false and lives must be polled. * - Analytics: per-video stats (/videos/{id}/stats/overall + viewers * timeseries) for owned videos, instance stats (/server/stats) otherwise. * * Rate limits: default instance config allows 50 API requests / 10 s per IP. * We run the SDK client at 2 requests/s per host — well under that budget, * shared by every connection on the same instance. */ import { createHash, randomUUID } from "node:crypto"; import type { Comment, ConnectorCapabilities, ConnectorConnection, CredentialRef, LiveStream, NormalizedIdentity, NormalizedVideoPublication, Page, Publication, RemoteVideoChannel, StreamPlaybackSource, VideoProcessingStatus, Visibility, } from "@tactical-media-broadcast-hub/core"; import { assertValidManifest, AuthError, ConnectorError, InMemoryCredentials, pageOf, RateLimitedHttpClient, type AuthorizationRequest, type AuthorizationResult, type ConnectorManifest, type CredentialAccessor, type PlatformConnector, } from "@tactical-media-broadcast-hub/connector-sdk"; import { resolvePeerTubeSource } from "./resolve.js"; const PROVIDER = "peertube"; const OTP_HEADER = "x-peertube-otp"; /** PeerTube validator bounds for video names and tags. */ const NAME_MIN = 3; const NAME_MAX = 120; const TAGS_MAX = 5; const CAPABILITIES: ConnectorCapabilities = { publishVideo: true, longFormVideo: true, comments: true, replies: true, livestreams: true, createLivestream: true, manageLivestream: true, livePlaybackSource: true, analytics: true, publishText: false, feed: false, directMessages: false, webhooks: false, }; /** What a single PeerTube channel can do — channels are the publishing unit. */ const CHANNEL_CAPABILITIES: ConnectorCapabilities = { publishVideo: true, longFormVideo: true, comments: true, replies: true, livestreams: true, createLivestream: true, manageLivestream: true, livePlaybackSource: true, }; /** Credential keys stored per connection through the CredentialAccessor. */ const SECRET = { accessToken: "access_token", refreshToken: "refresh_token", clientId: "client_id", clientSecret: "client_secret", } as const; /** Vault key for a live's RTMP stream key (owner-only, treated as a credential). */ export function streamKeySecretName(videoUuid: string): string { return `live_stream_key:${videoUuid}`; } /** VideoState ids (server/core/initializers/constants.ts, v8.3). */ export const VideoState = { PUBLISHED: 1, TO_TRANSCODE: 2, TO_IMPORT: 3, WAITING_FOR_LIVE: 4, LIVE_ENDED: 5, TO_MOVE_TO_EXTERNAL_STORAGE: 6, TRANSCODING_FAILED: 7, TO_MOVE_TO_EXTERNAL_STORAGE_FAILED: 8, TO_EDIT: 9, TO_MOVE_TO_FILE_SYSTEM: 10, TO_MOVE_TO_FILE_SYSTEM_FAILED: 11, TO_IMPORT_FAILED: 12, } as const; const FAILED_STATES: ReadonlySet = new Set([ VideoState.TRANSCODING_FAILED, VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED, VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED, VideoState.TO_IMPORT_FAILED, ]); /** LiveVideoLatencyMode: 1=Default, 2=High latency, 3=Small latency. */ export type PeerTubeLatencyMode = 1 | 2 | 3; /** VideoPrivacy: 1=Public, 2=Unlisted, 3=Private, 4=Internal (instance users). */ type PeerTubePrivacy = 1 | 2 | 3 | 4; const PRIVACY: Record = { public: 1, unlisted: 2, private: 3, // PeerTube has no follower-only privacy; "internal" means instance users. followers: null, provider_default: undefined, }; export interface PeerTubeVideoFile { bytes: Uint8Array; filename: string; mimeType: string; } export interface PeerTubeConnectorOptions { /** Stock PeerTube has no authorization-code grant; a bridge must issue API tokens. */ env?: Record; /** fetch override for tests (same pattern as the other connectors). */ fetchImpl?: typeof fetch; /** Requests per second per instance host. Default 2; tests raise it. */ requestsPerSecond?: number; /** Sleep override for the rate limiter (tests). */ sleep?: (ms: number) => Promise; /** Secret store. Default: in-memory (dev + contract tests). */ credentials?: CredentialAccessor; /** Read asset bytes (video file, thumbnail) by asset id — needed by uploadVideo. */ openAsset?: (assetId: string) => Promise; /** latencyMode used when a createLiveStream request does not set one. */ defaultLatencyMode?: PeerTubeLatencyMode; } /** * The SDK's AuthorizationRequest carries no provider input, so the * instance, username and one-time account secret travel in `fields` * (manifest field names) and an optional 2FA code in providerMetadata.otp. */ export interface PeerTubeAuthorizationRequest extends AuthorizationRequest { fields?: Record; providerMetadata?: Record; } /** createLiveStream input: the SDK request plus PeerTube-only knobs. */ export interface PeerTubeCreateLiveRequest { channelId: string; title: string; permanent: boolean; saveReplay: boolean; latencyMode?: PeerTubeLatencyMode; visibility?: Visibility; } interface PtAvatar { path?: string; fileUrl?: string; width?: number; } interface PtActor { id: number; name: string; displayName?: string; host: string; avatars?: PtAvatar[]; avatar?: PtAvatar | null; } interface PtUserMe { id: number; username: string; account: PtActor; videoChannels?: PtActor[]; } interface PtList { total: number; data: T[]; } interface PtStreamingPlaylist { id: number; /** 1 = HLS */ type: number; playlistUrl: string; } interface PtVideo { id: number; uuid: string; shortUUID?: string; name: string; isLive?: boolean; state?: { id: number; label?: string }; createdAt?: string; publishedAt?: string; url?: string; streamingPlaylists?: PtStreamingPlaylist[]; } interface PtVideoRef { video: { id: number; uuid: string; shortUUID?: string }; } interface PtLive { rtmpUrl?: string | null; rtmpsUrl?: string | null; streamKey?: string; saveReplay?: boolean; permanentLive?: boolean; latencyMode?: number; } interface PtLiveSession { id: number; startDate: string; endDate?: string | null; error?: number | null; } interface PtComment { id: number; text: string; createdAt: string; url?: string; isDeleted?: boolean; account?: PtActor | null; } interface PtToken { access_token: string; refresh_token: string; token_type: string; expires_in?: number; } interface PtOAuthClient { client_id: string; client_secret: string; } interface PtAbout { instance?: { name?: string }; } interface PtTimeseries { data?: { date: string; value: number }[]; } function str(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Accepts "video.example", "https://video.example/", etc. → origin. */ export function normalizeInstanceUrl(raw: string): string { const trimmed = raw.trim(); if (trimmed.length === 0) throw new ConnectorError("instanceUrl is required", PROVIDER); const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; let url: URL; try { url = new URL(withScheme); } catch (cause) { throw new ConnectorError(`instanceUrl "${raw}" is not a valid URL`, PROVIDER, cause); } const local = url.hostname === "localhost" || url.hostname === "127.0.0.1"; if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) { throw new ConnectorError(`instanceUrl must use https (got ${url.protocol})`, PROVIDER); } return url.origin; } /** * PeerTube handles are `name@host` without a leading `@` — a leading `@` * splits into an empty name and 404s, so users' pasted `@alice@host` is * normalized here. */ export function normalizeHandle(raw: string): string { return raw.trim().replace(/^@+/, ""); } const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", }; /** Comment text arrives as sanitized HTML; flatten it for the inbox. */ export function htmlToText(html: string): string { return html .replace(//gi, "\n") .replace(/<\/p>\s*

/gi, "\n\n") .replace(/<[^>]+>/g, "") .replace(/&(#\d+|[a-z]+);/gi, (match, entity: string) => { if (entity.startsWith("#")) return String.fromCodePoint(Number(entity.slice(1))); return ENTITIES[entity.toLowerCase()] ?? match; }) .trim(); } /** Every numeric top-level field of a stats payload, by name. */ function numericValues(payload: unknown): Record { const values: Record = {}; if (!isRecord(payload)) return values; for (const [key, value] of Object.entries(payload)) { if (typeof value === "number" && Number.isFinite(value)) values[key] = value; } return values; } function processingStateOf(video: PtVideo): VideoProcessingStatus["state"] { const id = video.state?.id; if (id === undefined) return "processing"; if (FAILED_STATES.has(id)) return "failed"; if ( id === VideoState.PUBLISHED || id === VideoState.WAITING_FOR_LIVE || id === VideoState.LIVE_ENDED ) { return "ready"; } return "processing"; } /** No LIVE VideoState exists: a running live is PUBLISHED with isLive=true. */ function liveStateOf(video: PtVideo): LiveStream["state"] { const id = video.state?.id; if (id === VideoState.WAITING_FOR_LIVE) return "waiting"; if (id === VideoState.LIVE_ENDED) return "ended"; if (id === VideoState.PUBLISHED && video.isLive === true) return "live"; return "created"; } export function createPeerTubeConnector(options: PeerTubeConnectorOptions = {}): PlatformConnector & { resolveSource: typeof resolvePeerTubeSource } { const client = new RateLimitedHttpClient({ requestsPerSecond: options.requestsPerSecond ?? 2, fetchImpl: options.fetchImpl, sleep: options.sleep, }); const credentials = options.credentials ?? new InMemoryCredentials(); /** GET /users/me per connection id — identity, account name, default channel. */ const selves = new Map(); let publicationSeq = 0; const manifest: ConnectorManifest = { id: PROVIDER, displayName: "PeerTube", capabilities: CAPABILITIES, connectionFields: [ { name: "instanceUrl", label: "Instance URL", required: true, secret: false, placeholder: "video.example.org", }, { name: "username", label: "Username", required: true, secret: false, placeholder: "alice", }, { // Account password, used once for the token exchange and never // stored — only the issued access/refresh tokens are kept. name: "accountSecret", label: "Account password (used once to obtain tokens, never stored)", required: true, secret: true, }, ], oauth: { kind: "per-instance-oauth2", pkce: true, connectEnabled: Boolean(options.env?.PEERTUBE_AUTHORIZE_PATH && options.env?.PEERTUBE_TOKEN_PATH), connectNote: "Redirect linking requires an instance OAuth bridge; stock PeerTube supports password grants only.", }, docsUrl: "https://docs.joinpeertube.org/api-rest-reference.html", }; function instanceOf(connection: ConnectorConnection): string { const raw = connection.fields.instanceUrl; if (raw === undefined) { throw new ConnectorError(`connection ${connection.id} has no instanceUrl`, PROVIDER); } return normalizeInstanceUrl(raw); } async function secretOrUndefined(connection: ConnectorConnection, key: string): Promise { try { return await credentials.getSecret(connection, key); } catch { return undefined; } } /** Maps PeerTube's problem+json errors onto the SDK error taxonomy. */ async function toError(response: Response, method: string, url: string): Promise { let detail = ""; let code: string | undefined; try { const body: unknown = await response.json(); if (isRecord(body)) { detail = str(body.detail) ?? str(body.error) ?? str(body.title) ?? ""; code = str(body.code); } } catch { detail = ""; } const message = `${PROVIDER}: HTTP ${response.status} ${method} ${new URL(url).pathname}${detail ? `: ${detail}` : ""}`; if (code === "missing_two_factor" || response.headers.get(OTP_HEADER) === "required") { return new AuthError( `${PROVIDER}: two-factor code required — retry authorize with providerMetadata.otp`, PROVIDER, true, ); } if (response.status === 401 || response.status === 403) { return new AuthError(message, PROVIDER, false); } return new ConnectorError(message, PROVIDER, { status: response.status, code }); } async function parse(response: Response): Promise { const text = await response.text(); return (text.length > 0 ? JSON.parse(text) : {}) as T; } /** Unauthenticated request (instance discovery + token endpoint). */ async function publicCall(url: string, init: RequestInit = {}): Promise { const response = await client.request(PROVIDER, url, { ...init, headers: { accept: "application/json", ...init.headers }, }); if (!response.ok) throw await toError(response, init.method ?? "GET", url); return parse(response); } async function tokenRequest( instanceUrl: string, params: Record, otp?: string, ): Promise { return publicCall(`${instanceUrl}/api/v1/users/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...(otp !== undefined ? { [OTP_HEADER]: otp } : {}), }, body: new URLSearchParams(params).toString(), }); } async function storeTokens(connection: ConnectorConnection, token: PtToken): Promise { await credentials.put(connection, SECRET.accessToken, token.access_token); await credentials.put(connection, SECRET.refreshToken, token.refresh_token); } async function refresh(connection: ConnectorConnection): Promise { const refreshToken = await secretOrUndefined(connection, SECRET.refreshToken); const clientId = await secretOrUndefined(connection, SECRET.clientId); const clientSecret = await secretOrUndefined(connection, SECRET.clientSecret); if (refreshToken === undefined || clientId === undefined || clientSecret === undefined) { throw new AuthError( `${PROVIDER}: no refresh token or client credentials stored — reconnect the account`, PROVIDER, false, ); } const token = await tokenRequest(instanceOf(connection), { grant_type: "refresh_token", client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, }); await storeTokens(connection, token); } /** * Authenticated call. Access tokens live ~1 day, so a 401 gets exactly one * refresh_token round-trip and a retry before surfacing as AuthError. */ async function api( connection: ConnectorConnection, method: string, path: string, body?: Record | FormData, retried = false, ): Promise { let token: string; try { token = await credentials.getAccessToken(connection); } catch { throw new AuthError(`${PROVIDER}: no access token for ${connection.id} — authorize first`, PROVIDER, false); } const headers: Record = { accept: "application/json", authorization: `Bearer ${token}` }; const init: RequestInit = { method, headers }; if (body instanceof FormData) { init.body = body; } else if (body !== undefined) { headers["content-type"] = "application/json"; init.body = JSON.stringify(body); } const url = `${instanceOf(connection)}${path}`; const response = await client.request(PROVIDER, url, init); if (response.status === 401 && !retried) { const canRefresh = (await secretOrUndefined(connection, SECRET.refreshToken)) !== undefined; if (canRefresh) { await refresh(connection); return api(connection, method, path, body, true); } } if (!response.ok) throw await toError(response, method, url); return parse(response); } async function self(connection: ConnectorConnection): Promise { const cached = selves.get(connection.id); if (cached !== undefined) return cached; const me = await api(connection, "GET", "/api/v1/users/me"); selves.set(connection.id, me); return me; } function avatarUrl(instanceUrl: string, actor: PtActor): string | undefined { const candidates = [...(actor.avatars ?? []), ...(actor.avatar ? [actor.avatar] : [])]; const best = candidates.reduce( (pick, avatar) => (pick === undefined || (avatar.width ?? 0) > (pick.width ?? 0) ? avatar : pick), undefined, ); if (best === undefined) return undefined; if (best.fileUrl !== undefined) return best.fileUrl; return best.path === undefined ? undefined : new URL(best.path, instanceUrl).toString(); } function handleOf(actor: PtActor): string { return `${actor.name}@${actor.host}`; } function identityOf(connection: ConnectorConnection, me: PtUserMe): NormalizedIdentity { const identity: NormalizedIdentity = { provider: PROVIDER, remoteId: String(me.account.id), handle: handleOf(me.account), displayName: me.account.displayName ?? me.username, }; const avatar = avatarUrl(instanceOf(connection), me.account); if (avatar !== undefined) identity.avatarUrl = avatar; return identity; } /** * PeerTube wants a numeric channelId. Accepts a numeric id, a * RemoteVideoChannel.id (`peertube::`), or a channel handle * (`name`, `name@host`, `@name@host`) resolved via /video-channels/{handle}. * Absent → the account's first channel. */ async function resolveChannelId(connection: ConnectorConnection, raw: string | undefined): Promise { if (raw === undefined) { const first = (await self(connection)).videoChannels?.[0]; if (first === undefined) { throw new ConnectorError(`${PROVIDER}: account has no video channel to publish to`, PROVIDER); } return first.id; } if (/^\d+$/.test(raw)) return Number(raw); const prefixed = new RegExp(`^${PROVIDER}:[^:]+:(\\d+)$`).exec(raw); if (prefixed?.[1] !== undefined) return Number(prefixed[1]); const handle = normalizeHandle(raw); const channel = await api( connection, "GET", `/api/v1/video-channels/${encodeURIComponent(handle)}`, ); return channel.id; } function privacyOf(visibility: Visibility | undefined, override: unknown): PeerTubePrivacy | undefined { if (override === 1 || override === 2 || override === 3 || override === 4) return override; if (visibility === undefined) return undefined; const privacy = PRIVACY[visibility]; if (privacy === null) { throw new ConnectorError( `${PROVIDER}: visibility "${visibility}" has no PeerTube equivalent — use public, unlisted, private, or providerMetadata.privacy=4 (internal)`, PROVIDER, ); } return privacy; } function videoPath(remoteId: string): string { return `/api/v1/videos/${encodeURIComponent(remoteId)}`; } async function getVideo(connection: ConnectorConnection, remoteId: string): Promise { return api(connection, "GET", videoPath(remoteId)); } /** Live master playlist is always master.m3u8 under the video's HLS dir. */ function liveHlsUrl(connection: ConnectorConnection, uuid: string): string { return `${instanceOf(connection)}/static/streaming-playlists/hls/${uuid}/master.m3u8`; } async function latestSession( connection: ConnectorConnection, remoteId: string, ): Promise { const sessions = await api>( connection, "GET", `/api/v1/videos/live/${encodeURIComponent(remoteId)}/sessions`, ); return sessions.data.reduce( (latest, session) => latest === undefined || Date.parse(session.startDate) > Date.parse(latest.startDate) ? session : latest, undefined, ); } function streamKeyRef(connection: ConnectorConnection, uuid: string): CredentialRef { return { kind: "credential-ref", id: `${connection.id}#${streamKeySecretName(uuid)}` }; } async function toLiveStream( connection: ConnectorConnection, video: PtVideo, withSession: boolean, ): Promise { const state = liveStateOf(video); const live: LiveStream = { id: `${PROVIDER}:${connection.id}:${video.uuid}`, provider: PROVIDER, connectionId: connection.id, remoteId: video.uuid, title: video.name, state, playbackUrls: [liveHlsUrl(connection, video.uuid)], }; if ((await secretOrUndefined(connection, streamKeySecretName(video.uuid))) !== undefined) { live.streamKeyRef = streamKeyRef(connection, video.uuid); } if (withSession && (state === "live" || state === "ended")) { const session = await latestSession(connection, video.uuid); if (session !== undefined) { live.startedAt = new Date(session.startDate); if (session.endDate) live.endedAt = new Date(session.endDate); } } return live; } function requireOpener(): (assetId: string) => Promise { const opener = options.openAsset; if (opener === undefined) { throw new ConnectorError(`${PROVIDER}: uploadVideo requires the openAsset option (asset id → bytes)`, PROVIDER); } return opener; } function commentRef(videoId: string, commentId: number): string { return `${videoId}:${commentId}`; } function toComment(videoId: string, comment: PtComment): Comment { return { id: `${PROVIDER}:${commentRef(videoId, comment.id)}`, provider: PROVIDER, remoteId: commentRef(videoId, comment.id), onRemoteId: videoId, authorHandle: comment.account ? handleOf(comment.account) : "deleted", text: htmlToText(comment.text), createdAt: new Date(comment.createdAt), }; } function offsetOf(cursor: string | undefined): number { if (cursor === undefined) return 0; const start = Number(cursor); if (!Number.isInteger(start) || start < 0) { throw new ConnectorError(`${PROVIDER}: invalid cursor "${cursor}"`, PROVIDER); } return start; } function nextCursor(start: number, received: number, total: number): string | undefined { const next = start + received; return received > 0 && next < total ? String(next) : undefined; } const connector: PlatformConnector = { async buildAuthorizationUrl(input) { const env = options.env ?? {}; const instanceUrl = normalizeInstanceUrl(input.fields?.instanceUrl ?? ""); const authorizePath = env.PEERTUBE_AUTHORIZE_PATH; const tokenPath = env.PEERTUBE_TOKEN_PATH; if (!authorizePath?.startsWith("/") || authorizePath.startsWith("//") || authorizePath.includes("\\") || !tokenPath?.startsWith("/") || tokenPath.startsWith("//") || tokenPath.includes("\\")) { throw new ConnectorError("PeerTube redirect linking requires a configured instance OAuth bridge", PROVIDER); } const connectionId = input.connectionId ?? randomUUID(); const connection: ConnectorConnection = { id: connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: connectionId, credentialId: { kind: "credential-ref", id: connectionId }, fields: { instanceUrl }, createdAt: new Date(), }; const configured = env.PEERTUBE_INSTANCE_URL !== undefined && normalizeInstanceUrl(env.PEERTUBE_INSTANCE_URL) === instanceUrl; const oauthClient = configured && env.PEERTUBE_CLIENT_ID && env.PEERTUBE_CLIENT_SECRET ? { client_id: env.PEERTUBE_CLIENT_ID, client_secret: env.PEERTUBE_CLIENT_SECRET } : await publicCall(`${instanceUrl}/api/v1/oauth-clients/local`); await credentials.put(connection, SECRET.clientId, oauthClient.client_id); await credentials.put(connection, SECRET.clientSecret, oauthClient.client_secret); const url = new URL(authorizePath, instanceUrl); url.search = new URLSearchParams({ client_id: oauthClient.client_id, redirect_uri: input.redirectUri, response_type: "code", state: input.state }).toString(); if (input.codeVerifier) { url.searchParams.set("code_challenge", createHash("sha256").update(input.codeVerifier).digest("base64url")); url.searchParams.set("code_challenge_method", "S256"); } return { authorizationUrl: url.toString(), connection }; }, async exchangeCode(input) { const instanceUrl = normalizeInstanceUrl(input.fields?.instanceUrl ?? ""); const connectionId = input.connectionId ?? randomUUID(); const connection: ConnectorConnection = { id: connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: connectionId, credentialId: { kind: "credential-ref", id: connectionId }, fields: { instanceUrl }, createdAt: new Date(), }; const path = options.env?.PEERTUBE_TOKEN_PATH; if (!path?.startsWith("/") || path.startsWith("//") || path.includes("\\")) throw new ConnectorError("OAuth bridge is not configured", PROVIDER); const token = await publicCall(new URL(path, instanceUrl).toString(), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: input.code, redirect_uri: input.redirectUri, client_id: await credentials.getSecret(connection, SECRET.clientId), client_secret: await credentials.getSecret(connection, SECRET.clientSecret), ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}), }).toString(), }); await storeTokens(connection, token); selves.delete(connection.id); return { connection, identity: identityOf(connection, await self(connection)) }; }, manifest: () => { assertValidManifest(manifest); return manifest; }, /** * Single leg: verify the instance (GET /config/about), fetch the local * OAuth client, exchange username + accountSecret for tokens, store the * tokens and client credentials. The account secret is not stored and * does not appear in the returned connection. */ async authorize(input: PeerTubeAuthorizationRequest): Promise { const fields = input.fields ?? {}; const meta = input.providerMetadata ?? {}; const rawInstance = str(fields.instanceUrl); const username = str(fields.username); const accountSecret = fields.accountSecret; if (rawInstance === undefined) throw new ConnectorError(`${PROVIDER}: authorize needs fields.instanceUrl`, PROVIDER); if (username === undefined) throw new ConnectorError(`${PROVIDER}: authorize needs fields.username`, PROVIDER); if (accountSecret === undefined || accountSecret.length === 0) { throw new ConnectorError(`${PROVIDER}: authorize needs fields.accountSecret (account password)`, PROVIDER); } const instanceUrl = normalizeInstanceUrl(rawInstance); const host = new URL(instanceUrl).host; // Instance discovery doubles as a "is this really PeerTube" check. try { await publicCall(`${instanceUrl}/api/v1/config/about`); } catch (cause) { throw new ConnectorError(`${PROVIDER}: ${host} does not answer as a PeerTube instance`, PROVIDER, cause); } const oauthClient = await publicCall(`${instanceUrl}/api/v1/oauth-clients/local`); const connectionId = str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}:${username}@${host}`; const connection: ConnectorConnection = { id: connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: str(meta.channelId) ?? connectionId, credentialId: { kind: "credential-ref", id: connectionId }, fields: { instanceUrl, username }, createdAt: new Date(), }; const token = await tokenRequest( instanceUrl, { grant_type: "password", client_id: oauthClient.client_id, client_secret: oauthClient.client_secret, username, password: accountSecret, }, str(meta.otp), ); await credentials.put(connection, SECRET.clientId, oauthClient.client_id); await credentials.put(connection, SECRET.clientSecret, oauthClient.client_secret); await storeTokens(connection, token); selves.delete(connection.id); const identity = identityOf(connection, await self(connection)); return { connection, identity }; }, async refreshCredentials(connection: ConnectorConnection): Promise { await refresh(connection); return connection.credentialId; }, async revokeCredentials(connection: ConnectorConnection): Promise { await api>(connection, "POST", "/api/v1/users/revoke-token"); selves.delete(connection.id); }, async getIdentity(connection: ConnectorConnection): Promise { return identityOf(connection, await self(connection)); }, async getRemoteVideoChannels(connection: ConnectorConnection): Promise { const me = await self(connection); const instanceUrl = instanceOf(connection); const channels = await api>( connection, "GET", `/api/v1/accounts/${encodeURIComponent(me.account.name)}/video-channels?start=0&count=100`, ); return channels.data.map((channel) => { const remote: RemoteVideoChannel = { id: `${PROVIDER}:${channel.host}:${channel.id}`, provider: PROVIDER, instanceUrl, remoteChannelId: String(channel.id), handle: handleOf(channel), capabilities: CHANNEL_CAPABILITIES, }; if (channel.displayName !== undefined) remote.displayName = channel.displayName; const avatar = avatarUrl(instanceUrl, channel); if (avatar !== undefined) remote.avatarUrl = avatar; return remote; }); }, /** * Legacy multipart upload (POST /api/v1/videos/upload). providerMetadata: * channelId (id or handle), privacy (1–4), category, licence, language, * nsfw, waitTranscoding. */ async uploadVideo( connection: ConnectorConnection, video: NormalizedVideoPublication, ): Promise<{ remoteId: string }> { const meta = video.providerMetadata; const open = requireOpener(); const file = await open(video.videoAssetId); const name = (video.title ?? file.filename.replace(/\.[^.]+$/, "")).trim(); if (name.length < NAME_MIN || name.length > NAME_MAX) { throw new ConnectorError( `${PROVIDER}: video name must be ${NAME_MIN}–${NAME_MAX} characters (got ${name.length})`, PROVIDER, ); } const tags = video.tags ?? []; if (tags.length > TAGS_MAX) { throw new ConnectorError(`${PROVIDER}: at most ${TAGS_MAX} tags per video (got ${tags.length})`, PROVIDER); } const channelId = await resolveChannelId(connection, str(meta.channelId) ?? str(connection.fields.channelId)); const privacy = privacyOf(video.visibility, meta.privacy); const form = new FormData(); form.append("videofile", new Blob([file.bytes], { type: file.mimeType }), file.filename); form.append("name", name); form.append("channelId", String(channelId)); if (privacy !== undefined) form.append("privacy", String(privacy)); if (video.description !== undefined) form.append("description", video.description); // Same array encoding as PeerTube's own web client (objectToFormData). tags.forEach((tag, index) => form.append(`tags[${index}]`, tag)); for (const key of ["category", "licence"] as const) { if (typeof meta[key] === "number") form.append(key, String(meta[key])); } if (typeof meta.language === "string") form.append("language", meta.language); if (typeof meta.nsfw === "boolean") form.append("nsfw", String(meta.nsfw)); if (typeof meta.waitTranscoding === "boolean") form.append("waitTranscoding", String(meta.waitTranscoding)); if (video.thumbnailAssetId !== undefined) { const thumb = await open(video.thumbnailAssetId); form.append("thumbnailfile", new Blob([thumb.bytes], { type: thumb.mimeType }), thumb.filename); } const result = await api(connection, "POST", "/api/v1/videos/upload", form); return { remoteId: result.video.uuid }; }, async getVideoStatus(connection: ConnectorConnection, remoteId: string): Promise { const video = await getVideo(connection, remoteId); const state = processingStateOf(video); const status: VideoProcessingStatus = { remoteId: video.uuid, state }; if (state === "failed") status.error = video.state?.label ?? `state ${video.state?.id ?? "unknown"}`; return status; }, async getVideos( connection: ConnectorConnection, request: { cursor?: string; limit: number }, ): Promise> { const start = offsetOf(request.cursor); const params = new URLSearchParams({ start: String(start), count: String(request.limit), sort: "-createdAt" }); const list = await api>(connection, "GET", `/api/v1/users/me/videos?${params}`); const items = list.data.map((video) => { const item: { remoteId: string; title?: string; createdAt?: Date } = { remoteId: video.uuid, title: video.name }; if (video.createdAt !== undefined) item.createdAt = new Date(video.createdAt); return item; }); return pageOf(items, nextCursor(start, list.data.length, list.total)); }, async getComments( connection: ConnectorConnection, request: { onRemoteId: string; cursor?: string; limit: number }, ): Promise> { const start = offsetOf(request.cursor); const params = new URLSearchParams({ start: String(start), count: String(request.limit), sort: "-createdAt" }); const list = await api>( connection, "GET", `${videoPath(request.onRemoteId)}/comment-threads?${params}`, ); const items = list.data .filter((comment) => comment.isDeleted !== true) .map((comment) => toComment(request.onRemoteId, comment)); return pageOf(items, nextCursor(start, list.data.length, list.total)); }, /** * onRemoteId `