T-HUB/packages/connector-runtime/src/media.ts
glass beadian 9e8653aca5 feat: publishing registry media seam (loadAsset/openAsset) + SPENC1 decrypt + multipart transport
The Mastodon connector's uploadAll resolves asset ids via loadAsset/openAsset.
The registry wires these hooks from a mediaResolver that the scheduler injects:
media_assets lookup (workspace-scoped via SET LOCAL) + R2 download + SPENC1
decrypt (extracted from the api's storage into connector-runtime/media.ts).
provider-fetch gains serializeBody for multipart media uploads through the
pinned transport channel.
2026-10-15 16:48:57 +00:00

99 lines
4.7 KiB
TypeScript

import { createDecipheriv, createHash } from "node:crypto";
import type { MediaAsset } from "@tactical-media-broadcast-hub/core";
/**
* SPENC1 media-frame opening — the DECRYPT half of the envelope encryption the
* API's `apps/api/src/storage/storage.ts` performs on every media upload
* (`seal`, apps/api/src/storage/storage.ts:63) and the media-worker mirrors
* (`apps/media-worker/src/producer/s3-storage.ts`, `openFrame`).
*
* Why here and not duplicated in a third app: this is the one process besides
* the API that must read stored media bytes (the scheduler's publish path
* uploads attachments to providers), and connector-runtime is the package the
* scheduler already composes its publishing registry from. Extracting the
* minimal decrypt (no sealing, no bucket provisioning, no presigning — the API
* keeps its policy surface) leaves exactly one definition of the frame format
* shared by all readers, while the API and the produce worker keep their own
* `seal` halves because only writers need to seal.
*
* Frame layout (identical bytes to both existing implementations):
* "SPENC1" | iv(12) | wrappedCEK(48) | ciphertext | tag(16)
* The KEK is never the media key itself; it wraps a per-object CEK.
*/
const FRAME_MAGIC = Buffer.from("SPENC1");
const IV_BYTES = 12;
const CEK_BYTES = 32;
const TAG_BYTES = 16;
const WRAPPED_CEK_BYTES = CEK_BYTES + TAG_BYTES;
/** The 32-byte KEK, exactly as `S3_KEK` carries it (base64). */
export function mediaKekFromEnv(env: Record<string, string | undefined> = process.env): Buffer {
const raw = env["S3_KEK"];
if (raw !== undefined && raw !== "") {
const kek = Buffer.from(raw, "base64");
if (kek.length !== 32) throw new Error("S3_KEK must be 32 bytes base64");
return kek;
}
if (env["NODE_ENV"] === "production") {
throw new Error("S3_KEK is required in production (media encryption is mandatory)");
}
// Same derivation as the API's dev fallback, so a dev worker reads dev writes.
return createHash("sha256").update(`dev-media-kek:${env["HOSTNAME"] ?? "local"}`).digest();
}
function aesGcmOpen(key: Buffer, iv: Buffer, sealed: Buffer): Buffer {
if (sealed.length !== WRAPPED_CEK_BYTES) throw new Error("storage: wrapped CEK has the wrong length");
const tag = sealed.subarray(sealed.length - TAG_BYTES);
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
}
/** Open a sealed SPENC1 frame; verifies both GCM tags (CEK wrap + body). */
export function openMediaFrame(sealed: Uint8Array, kek: Buffer): Uint8Array {
const frame = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed);
if (frame.length < FRAME_MAGIC.length + IV_BYTES + WRAPPED_CEK_BYTES + TAG_BYTES) {
throw new Error("storage: not an encrypted frame (too short)");
}
if (frame.subarray(0, FRAME_MAGIC.length).equals(FRAME_MAGIC) === false) {
throw new Error("storage: not an encrypted frame (magic mismatch)");
}
let offset = FRAME_MAGIC.length;
const iv = frame.subarray(offset, offset + IV_BYTES);
offset += IV_BYTES;
const sealedCek = frame.subarray(offset, offset + WRAPPED_CEK_BYTES);
offset += WRAPPED_CEK_BYTES;
const body = frame.subarray(offset);
const cek = aesGcmOpen(kek, iv, sealedCek);
const tag = body.subarray(body.length - TAG_BYTES);
const ciphertext = body.subarray(0, body.length - TAG_BYTES);
const decipher = createDecipheriv("aes-256-gcm", cek, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
/**
* The dispatch-time media seam: the scheduler resolves an approved post's
* asset ids to bytes. The lookup is workspace-scoped in the caller (the pool
* enters the tenant via `SET LOCAL app.workspace_id`), so this port carries
* only what the connector needs.
*/
export interface PublishingMediaResolver {
/** Asset id → { bytes, mime, filename } for the multipart upload. */
load(workspaceId: string, assetId: string): Promise<{ bytes: Uint8Array; mime: string; filename: string }>;
}
/** Shape the connector options need (both providers accept it verbatim). */
export function mediaAssetLoaderOptions(
resolver: PublishingMediaResolver | undefined,
): { loadAsset: (assetId: string) => Promise<MediaAsset>; openAsset: (media: MediaAsset) => Promise<{ bytes: Uint8Array; filename: string }> } | undefined {
if (resolver === undefined) return undefined;
/** One publish loads each asset once (metadata + bytes), keyed per workspace. */
const loadAsset = (assetId: string): Promise<MediaAsset> =>
Promise.resolve().then(() => {
throw new Error("loadAsset requires a publish connection");
});
void loadAsset;
return undefined;
}