fix: wire scheduler media resolver into publishing registry

The registry accepted media hooks but nothing supplied them, so Mastodon
media posts failed uploadAll (INVALID_POST attempts). PublishingMediaResolver
in the scheduler resolves workspace-scoped media_assets rows via RLS tenant
context and reads plaintext bytes from S3 with SPENC1 open (S3_KEK), matching
the api's S3Storage frame format.
This commit is contained in:
glass beadian 2026-10-15 18:44:18 +00:00
parent 9e8653aca5
commit b8115eec09
4 changed files with 107 additions and 34 deletions

View file

@ -6,4 +6,5 @@ export * from "./publish.js";
export * from "./vault.js";
export * from "./provider-fetch.js";
export * from "./credentials.js";
export * from "./media.js";
export * from "./registry.js";

View file

@ -1,52 +1,43 @@
import type { Pool } from "pg";
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import type { MediaAsset } from "@tactical-media-broadcast-hub/core";
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import { createPublishingCredentialAccessor } from "./credentials.js";
import { providerFetch } from "./provider-fetch.js";
import { executionSignal } from "./execution-context.js";
import { openMediaFrame, mediaKekFromEnv } from "./media.js";
/** Production composition registers real connectors, once per worker process. */
/**
* Publish-time media seam. The scheduler provides an implementation that
* resolves a workspace-scoped media asset id to its metadata and bytes
* (S3 download + SPENC1 open); the registry adapts it to the connector
* options, threading the publish connection's workspace through.
*/
export interface PublishingMediaHooks {
loadAsset(workspaceId: string, assetId: string): Promise<{ storageKey: string; mimeType: string; sizeBytes: number }>;
openAsset(workspaceId: string, storageKey: string): Promise<Uint8Array>;
loadAsset(workspaceId: string, assetId: string): Promise<MediaAsset>;
openAsset(workspaceId: string, asset: MediaAsset): Promise<Uint8Array>;
}
const EXT_FOR_MIME: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"video/mp4": "mp4",
"video/webm": "webm",
};
/** Production composition registers real connectors, once per worker process. */
export function createPublishingRegistry(pool: Pool, media?: PublishingMediaHooks): ConnectorRegistry {
const registry = new ConnectorRegistry();
const fetchImpl: typeof fetch = (input, init) => providerFetch(input, { ...init, signal: executionSignal() ?? init?.signal });
const options: Record<string, unknown> = { credentials: createPublishingCredentialAccessor(pool), fetchImpl, env: process.env };
if (media) {
// Wire the media hooks so the Mastodon connector can resolve asset ids to
// bytes at publish time. The workspace comes from the publish call's
// connection (the Temporal activity carries it).
const kek = mediaKekFromEnv(process.env);
options.loadAsset = async (connection: { workspaceId: string }, assetId: string) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [connection.workspaceId]);
const row = await client.query(
"SELECT id, workspace_id, storage_key, mime, size_bytes FROM media_assets WHERE id = $1",
[assetId],
);
await client.query("COMMIT");
const r = row.rows[0];
if (!r) throw new Error(`media asset ${assetId} not found`);
return { id: r.id, workspaceId: r.workspace_id, storageKey: r.storage_key, mimeType: r.mime, sizeBytes: Number(r.size_bytes), createdAt: new Date() };
} finally { client.release(); }
};
options.openAsset = async (connection: { workspaceId: string }, asset: { storageKey: string }) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [connection.workspaceId]);
const sealed = await client.query("SELECT api.storage_get_object($1) AS bytes", [asset.storageKey]);
await client.query("COMMIT");
return { bytes: new Uint8Array(openMediaFrame(sealed.rows[0].bytes, kek)), filename: asset.storageKey.split("/").pop() ?? "asset" };
} finally { client.release(); }
};
options.loadAsset = (connection: { workspaceId: string }, assetId: string): Promise<MediaAsset> =>
media.loadAsset(connection.workspaceId, assetId);
options.openAsset = async (connection: { workspaceId: string }, asset: MediaAsset) => ({
bytes: await media.openAsset(connection.workspaceId, asset),
filename: `${EXT_FOR_MIME[asset.mimeType] ?? "bin"}`,
});
}
registry.register(createMastodonConnector(options));
registry.register(createTelegramConnector(options));