127 lines
4.0 KiB
TypeScript
127 lines
4.0 KiB
TypeScript
import {
|
|
SAND_PERM_CODES,
|
|
SandboxPermissionError,
|
|
} from "@suaveplan/api-sandbox-permissions";
|
|
import type {
|
|
SerializedRequest,
|
|
SerializedResponse,
|
|
} from "@suaveplan/api-types/sandbox-bridge";
|
|
import {
|
|
SAND_WASM_BRIDGE_CODES,
|
|
SandboxWasmBridgeError,
|
|
} from "../error-codes/error-codes.js";
|
|
|
|
/**
|
|
* Shape the bridge factory threads through its `RequestContext` slot.
|
|
* Defined locally to avoid leaking the host's `RequestContext<T>`
|
|
* generic into this utility — both variants need the same five fields
|
|
* and nothing more.
|
|
*/
|
|
export interface RequestContextLike {
|
|
readonly request: Request;
|
|
readonly principal: { readonly id: string } | null;
|
|
readonly tenant: { readonly id: string } | null;
|
|
readonly capabilities: Readonly<Record<string, unknown>>;
|
|
readonly requestId: string;
|
|
}
|
|
|
|
/**
|
|
* Marshal a `RequestContextLike` into the wire `SerializedRequest`
|
|
* shape both variants exchange with their guest plugins.
|
|
*/
|
|
export async function marshalContextRequest(
|
|
ctx: RequestContextLike
|
|
): Promise<SerializedRequest> {
|
|
const request = ctx.request;
|
|
const headers: Array<readonly [string, string]> = [];
|
|
request.headers.forEach((value, name) => {
|
|
headers.push([name.toLowerCase(), value] as const);
|
|
});
|
|
let bodyBase64 = "";
|
|
if (request.body !== null) {
|
|
const buffer = await request.arrayBuffer();
|
|
bodyBase64 = bytesToBase64UrlNoPad(new Uint8Array(buffer));
|
|
}
|
|
return {
|
|
url: request.url,
|
|
method: request.method.toUpperCase(),
|
|
headers,
|
|
bodyBase64,
|
|
principalId: (ctx.principal === null
|
|
? null
|
|
: ctx.principal.id) as SerializedRequest["principalId"],
|
|
tenantId: (ctx.tenant === null
|
|
? null
|
|
: ctx.tenant.id) as SerializedRequest["tenantId"],
|
|
capabilities: Object.keys(ctx.capabilities),
|
|
requestId: ctx.requestId,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert the wire `SerializedResponse` into a host `Response`. When
|
|
* the wire response carries an `errorCode`, surface the matching
|
|
* sandbox error (preserving the canonical `SAND_PERM.DENIED` mapping).
|
|
*/
|
|
export function buildHostResponse(
|
|
wire: SerializedResponse,
|
|
pluginName: string
|
|
): Response {
|
|
if (wire.errorCode !== undefined) {
|
|
if (wire.errorCode === SAND_PERM_CODES.DENIED) {
|
|
throw new SandboxPermissionError(
|
|
SAND_PERM_CODES.DENIED,
|
|
wire.errorMessage ?? "capability denied",
|
|
{ context: { plugin: pluginName } }
|
|
);
|
|
}
|
|
throw new SandboxWasmBridgeError(
|
|
SAND_WASM_BRIDGE_CODES.CAPABILITY_DENIED,
|
|
wire.errorCode,
|
|
{
|
|
context: {
|
|
plugin: pluginName,
|
|
sandboxErrorCode: wire.errorCode,
|
|
sandboxMessage: wire.errorMessage ?? "",
|
|
},
|
|
}
|
|
);
|
|
}
|
|
const headers = new Headers();
|
|
for (const [name, value] of wire.headers) headers.append(name, value);
|
|
const body = base64UrlNoPadToBytes(wire.bodyBase64);
|
|
return new Response(
|
|
body.length > 0 ? (body as unknown as BodyInit) : null,
|
|
{
|
|
status: wire.status,
|
|
headers,
|
|
}
|
|
);
|
|
}
|
|
|
|
function bytesToBase64UrlNoPad(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i] as number);
|
|
}
|
|
return btoa(binary)
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
}
|
|
|
|
function base64UrlNoPadToBytes(value: string): Uint8Array {
|
|
if (value.length === 0) return new Uint8Array(0);
|
|
const padded = value
|
|
.replace(/-/g, "+")
|
|
.replace(/_/g, "/")
|
|
.padEnd(value.length + ((4 - (value.length % 4)) % 4), "=");
|
|
const binary = atob(padded);
|
|
const out = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
out[i] = binary.charCodeAt(i);
|
|
}
|
|
return out;
|
|
}
|