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:
parent
77b32ef70f
commit
a6843e904b
1 changed files with 57 additions and 2 deletions
|
|
@ -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) : {}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue