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.
This commit is contained in:
parent
a6843e904b
commit
9e8653aca5
5 changed files with 207 additions and 17 deletions
|
|
@ -17,21 +17,70 @@ export function publicAddress(address: string): boolean {
|
|||
return family === 6 && /^[23][0-9a-f]{3}:/i.test(address) && !blocked.check(address, "ipv6");
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a request body for the pinned transport. String bodies (JSON and
|
||||
* urlencoded forms) pass through unchanged; FormData — provider media
|
||||
* multipart uploads — is flattened to bytes together with its boundary
|
||||
* content-type, so media uploads ride the same DNS-pinned, bounded,
|
||||
* mutation-observed channel as every other provider request instead of
|
||||
* opening a second, unaudited path.
|
||||
*/
|
||||
async function serializeBody(
|
||||
body: NonNullable<RequestInit["body"]>,
|
||||
): Promise<{ bytes: Buffer; contentType?: string }> {
|
||||
if (typeof body === "string") return { bytes: Buffer.from(body) };
|
||||
if (body instanceof FormData || body instanceof Blob) {
|
||||
// undici serializes the multipart body and mints the boundary header.
|
||||
const request = new Request("http://localhost.internal/", { method: "POST", body });
|
||||
const contentType = request.headers.get("content-type") ?? undefined;
|
||||
return { bytes: Buffer.from(await request.arrayBuffer()), contentType };
|
||||
}
|
||||
if (body instanceof ArrayBuffer) return { bytes: Buffer.from(body) };
|
||||
if (ArrayBuffer.isView(body)) return { bytes: Buffer.from(body.buffer, body.byteOffset, body.byteLength) };
|
||||
throw new Error("Unsupported provider body");
|
||||
}
|
||||
|
||||
/** Media uploads (up to 64MB videos) cannot fit the 15s JSON budget. */
|
||||
const UPLOAD_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** Rough request-body size, only to pick the upload deadline. */
|
||||
function bodyBudgetOf(init: RequestInit): number {
|
||||
const body = init.body;
|
||||
if (body === undefined || body === null) return 0;
|
||||
if (typeof body === "string") return body.length;
|
||||
if (body instanceof ArrayBuffer) return body.byteLength;
|
||||
if (ArrayBuffer.isView(body)) return body.byteLength;
|
||||
if (body instanceof Blob) return body.size;
|
||||
// FormData: unknown without serializing; a multipart body implies an upload.
|
||||
if (body instanceof FormData) return Number.MAX_SAFE_INTEGER;
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/** Connect-only transport: DNS-pinned public destinations, no redirects, bounded JSON. */
|
||||
export const providerFetch: typeof fetch = async (input, init = {}) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
const local = process.env.NODE_ENV !== "production" && (hostname === "localhost" || hostname === "127.0.0.1");
|
||||
if (url.username || url.password || (url.protocol !== "https:" && !(local && url.protocol === "http:"))) throw new Error("Invalid provider URL");
|
||||
const signal = AbortSignal.any([AbortSignal.timeout(15_000), ...(init.signal ? [init.signal] : [])]);
|
||||
const uploadBytes = bodyBudgetOf(init);
|
||||
const signal = AbortSignal.any([AbortSignal.timeout(uploadBytes > 1_048_576 ? UPLOAD_TIMEOUT_MS : 15_000), ...(init.signal ? [init.signal] : [])]);
|
||||
signal.throwIfAborted();
|
||||
const addresses = await Promise.race([lookup(hostname, { all: true }), new Promise<never>((_resolve, reject) => { signal.addEventListener("abort", () => reject(new Error("PROVIDER_TIMEOUT")), { once: true }); })]);
|
||||
signal.throwIfAborted();
|
||||
if (!addresses.length || (!local && addresses.some(({ address }) => !publicAddress(address)))) throw new Error("Provider must resolve to public addresses");
|
||||
const pinned = addresses[0];
|
||||
if (!pinned) throw new Error("Provider DNS unavailable");
|
||||
if (init.body !== undefined && init.body !== null && typeof init.body !== "string") throw new Error("Unsupported authorization body");
|
||||
const headers = Object.fromEntries(new Headers(init.headers).entries());
|
||||
let body: Buffer | undefined;
|
||||
if (init.body !== undefined && init.body !== null) {
|
||||
const serialized = await serializeBody(init.body);
|
||||
body = serialized.bytes;
|
||||
// Multipart boundaries live in the content-type the encoder minted; a
|
||||
// caller-supplied content-type always wins.
|
||||
if (serialized.contentType !== undefined && headers["content-type"] === undefined) {
|
||||
headers["content-type"] = serialized.contentType;
|
||||
}
|
||||
}
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
|
||||
method: init.method ?? "GET", headers, family: pinned.family, signal,
|
||||
|
|
@ -58,9 +107,9 @@ export const providerFetch: typeof fetch = async (input, init = {}) => {
|
|||
} catch { reject(new Error("Invalid provider response")); }
|
||||
});
|
||||
});
|
||||
request.setTimeout(15_000, () => request.destroy(new Error("Provider timeout")));
|
||||
request.setTimeout(uploadBytes > 1_048_576 ? UPLOAD_TIMEOUT_MS : 15_000, () => request.destroy(new Error("Provider timeout")));
|
||||
request.on("error", reject);
|
||||
if (init.body) request.write(init.body);
|
||||
if (body !== undefined) request.write(body);
|
||||
request.end();
|
||||
});
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue