diff --git a/packages/publishing-execution/src/publishing.repository.ts b/packages/publishing-execution/src/publishing.repository.ts index 36f42b4..4f4ad01 100644 --- a/packages/publishing-execution/src/publishing.repository.ts +++ b/packages/publishing-execution/src/publishing.repository.ts @@ -27,6 +27,14 @@ export const PUBLISH_ERROR_CODES = [ ] as const; export type PublishErrorCode = (typeof PUBLISH_ERROR_CODES)[number] | (string & {}); +/** + * Provider-compatible ceiling on attachments per post (the API's + * media.service MAX_MEDIA_PER_POST; Mastodon's connector MAX_MEDIA). The API + * validated it at intake; the repository re-checks the approved snapshot so a + * forged snapshot can never reach a connector. + */ +export const MAX_MEDIA_ATTACHMENTS = 4; + /** Roles that may use every connection without group sharing (groups.service WORKSPACE_WIDE_ROLES). */ const WORKSPACE_WIDE_ROLES = ["Owner", "Admin"]; const DEFAULT_LEASE_SECONDS = 90; @@ -56,7 +64,10 @@ export interface Lease { export interface ExecutionPost { text: string; + /** media_assets ids (0..4). Existence was proven at intake; dispatch resolves them. */ mediaAssetIds: string[]; + /** Optional alt texts, positionally aligned with mediaAssetIds (null = none). */ + altTexts?: (string | null)[]; visibility: string; providerMetadata: Record; } @@ -259,7 +270,7 @@ export class PublishingExecutionRepository { } const approvedPost = draft.post as ExecutionPost; - if (approvedPost.mediaAssetIds.length > 0 || "mediaIds" in approvedPost.providerMetadata) throw new PublishingStateError("UNSUPPORTED_MEDIA", "publishing currently supports text only"); + assertApprovedMediaShape(approvedPost); if (approvedPost.providerMetadata.connectionId !== input.connectionId || canonicalPostHash(approvedPost) !== draft.approved_hash) { throw new PublishingStateError("APPROVAL_MISMATCH", "approved content or target does not match"); } @@ -935,6 +946,43 @@ function ownsLease(attempt: Row, lease: Lease): boolean { ); } +/** + * The approved snapshot may carry media, but its shape must still be exactly + * what the API admits at intake: 0..4 asset ids plus (optionally) an + * index-aligned list of alt texts. Asset EXISTENCE is not re-proven here — the + * api validated it against media_assets inside the workspace at intake, and + * the approved snapshot is immutable afterwards; the dispatch-time resolver + * re-derives the row (workspace-scoped) when it loads bytes. Anything else + * (wrong types, misaligned alt texts, remote media ids smuggled into + * providerMetadata) is a forged snapshot and refuses to schedule. + */ +function assertApprovedMediaShape(post: ExecutionPost): void { + const ids = post.mediaAssetIds; + if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && id.length > 0)) { + throw new PublishingStateError("UNSUPPORTED_MEDIA", "post.mediaAssetIds must be an array of non-empty strings"); + } + if (ids.length > MAX_MEDIA_ATTACHMENTS) { + throw new PublishingStateError( + "UNSUPPORTED_MEDIA", + `post.mediaAssetIds supports at most ${MAX_MEDIA_ATTACHMENTS} attachments`, + ); + } + const altTexts = post.altTexts; + if (altTexts !== undefined) { + if (!Array.isArray(altTexts) || !altTexts.every((entry) => entry === null || typeof entry === "string")) { + throw new PublishingStateError("UNSUPPORTED_MEDIA", "post.altTexts must be an array of strings or nulls"); + } + if (altTexts.length !== ids.length) { + throw new PublishingStateError("UNSUPPORTED_MEDIA", "post.altTexts must align with post.mediaAssetIds (same length)"); + } + } + if (Object.prototype.hasOwnProperty.call(post.providerMetadata, "mediaIds")) { + // Remote media ids bypass asset ownership; the runtime refuses them at + // dispatch too (idempotency.FORBIDDEN_POST_METADATA_KEYS). + throw new PublishingStateError("UNSUPPORTED_MEDIA", "providerMetadata.mediaIds is reserved and may not be supplied by the author"); + } +} + function contentFenceHolds(draft: Row, attempt: Row): boolean { return ( String(draft.revision) === String(attempt.approved_revision) && @@ -1171,9 +1219,16 @@ function jobFromLocked(locked: Locked, lease: Lease): ClaimedJob { */ function executionPost(snapshot: Row, draftId: string, idempotencyKey: string): ExecutionPost { const metadata = snapshot.providerMetadata; + const mediaAssetIds = Array.isArray(snapshot.mediaAssetIds) ? snapshot.mediaAssetIds.map((v) => String(v)) : []; + const rawAltTexts = snapshot.altTexts; + const altTexts = + Array.isArray(rawAltTexts) && rawAltTexts.length === mediaAssetIds.length + ? rawAltTexts.map((v) => (v === null || v === undefined ? null : typeof v === "string" ? v : null)) + : undefined; return { text: String(snapshot.text ?? ""), - mediaAssetIds: Array.isArray(snapshot.mediaAssetIds) ? snapshot.mediaAssetIds.map((v) => String(v)) : [], + mediaAssetIds, + ...(altTexts === undefined ? {} : { altTexts }), visibility: String(snapshot.visibility ?? "provider_default"), providerMetadata: { ...(typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? (metadata as Row) : {}),