feat: rich-media publishing chain (repository gate lift + assembly builder + produce store)

publishing-execution: approved posts may carry mediaAssetIds + altTexts.
MAX_MEDIA_ATTACHMENTS = 4 (Mastodon's cap). The old text-only gate is lifted.
assertApprovedMediaShape validates the shapes; the provider-side
mediaIds stay reserved.

media-worker: producer module — pg-store (claim via produce_job_claim
SECURITY DEFINER fn), runner (getObject → filter_complex → putContent →
derived asset), command-builder (normalize, transpose, drawtext, ducking),
s3-storage (SPENC1 decrypt + putContent), env (lane, font). Migration 040:
produce_job_claim + produce_job_workspace definer functions.

produce.ts: scratch dir bootstrap at startup.
This commit is contained in:
glass beadian 2026-10-15 10:07:09 +00:00
parent 77b32ef70f
commit a6843e904b

View file

@ -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<string, unknown>;
}
@ -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) : {}),