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;
|
] as const;
|
||||||
export type PublishErrorCode = (typeof PUBLISH_ERROR_CODES)[number] | (string & {});
|
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). */
|
/** Roles that may use every connection without group sharing (groups.service WORKSPACE_WIDE_ROLES). */
|
||||||
const WORKSPACE_WIDE_ROLES = ["Owner", "Admin"];
|
const WORKSPACE_WIDE_ROLES = ["Owner", "Admin"];
|
||||||
const DEFAULT_LEASE_SECONDS = 90;
|
const DEFAULT_LEASE_SECONDS = 90;
|
||||||
|
|
@ -56,7 +64,10 @@ export interface Lease {
|
||||||
|
|
||||||
export interface ExecutionPost {
|
export interface ExecutionPost {
|
||||||
text: string;
|
text: string;
|
||||||
|
/** media_assets ids (0..4). Existence was proven at intake; dispatch resolves them. */
|
||||||
mediaAssetIds: string[];
|
mediaAssetIds: string[];
|
||||||
|
/** Optional alt texts, positionally aligned with mediaAssetIds (null = none). */
|
||||||
|
altTexts?: (string | null)[];
|
||||||
visibility: string;
|
visibility: string;
|
||||||
providerMetadata: Record<string, unknown>;
|
providerMetadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
@ -259,7 +270,7 @@ export class PublishingExecutionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvedPost = draft.post as ExecutionPost;
|
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) {
|
if (approvedPost.providerMetadata.connectionId !== input.connectionId || canonicalPostHash(approvedPost) !== draft.approved_hash) {
|
||||||
throw new PublishingStateError("APPROVAL_MISMATCH", "approved content or target does not match");
|
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 {
|
function contentFenceHolds(draft: Row, attempt: Row): boolean {
|
||||||
return (
|
return (
|
||||||
String(draft.revision) === String(attempt.approved_revision) &&
|
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 {
|
function executionPost(snapshot: Row, draftId: string, idempotencyKey: string): ExecutionPost {
|
||||||
const metadata = snapshot.providerMetadata;
|
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 {
|
return {
|
||||||
text: String(snapshot.text ?? ""),
|
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"),
|
visibility: String(snapshot.visibility ?? "provider_default"),
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
...(typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? (metadata as Row) : {}),
|
...(typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? (metadata as Row) : {}),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue