202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
import type {
|
|
SerializedRequest,
|
|
SerializedResponse,
|
|
} from "@suaveplan/api-types/sandbox-bridge";
|
|
import {
|
|
buildFailResponsePayload,
|
|
createInvoice,
|
|
finaliseInvoice,
|
|
getInvoiceOrThrow,
|
|
type InMemoryStore,
|
|
listInvoices,
|
|
parseCreateInvoiceInput,
|
|
raiseSubscriptionInactive,
|
|
} from "../domain/index.js";
|
|
import { getStore } from "./wasm-state.js";
|
|
|
|
/**
|
|
* Routing + handler entry point for the wasm-tier surface. Receives a
|
|
* `SerializedRequest` (the WASI-stdio envelope payload), matches against
|
|
* the static route table by method + URL pathname, and dispatches to a
|
|
* per-route handler that delegates every business decision to
|
|
* `src/domain/invoice-rules.ts`.
|
|
*
|
|
* `SuaveplanError`-shaped throws are NOT caught here — the ABI shim
|
|
* in `wasm-compiler-javy` wraps `handleRequest` and emits an
|
|
* `{ kind: "error" }` envelope when a thrown `SuaveplanError` reaches
|
|
* the boundary.
|
|
*/
|
|
export async function handleRequest(
|
|
req: SerializedRequest
|
|
): Promise<SerializedResponse> {
|
|
const url = new URL(req.url);
|
|
const pathname = url.pathname;
|
|
const store = getStore();
|
|
const result = await dispatch({
|
|
method: req.method.toUpperCase(),
|
|
pathname,
|
|
search: url.searchParams,
|
|
body: decodeBody(req.bodyBase64),
|
|
capabilities: req.capabilities,
|
|
store,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
interface DispatchCtx {
|
|
readonly method: string;
|
|
readonly pathname: string;
|
|
readonly search: URLSearchParams;
|
|
readonly body: Uint8Array;
|
|
readonly capabilities: readonly string[];
|
|
readonly store: InMemoryStore;
|
|
}
|
|
|
|
async function dispatch(ctx: DispatchCtx): Promise<SerializedResponse> {
|
|
if (ctx.method === "GET" && ctx.pathname === "/example/public/ping") {
|
|
return jsonResponse(200, {
|
|
ok: true,
|
|
pluginName: "@suaveplan/api-plugin-example",
|
|
});
|
|
}
|
|
if (ctx.method === "GET" && ctx.pathname === "/example/health") {
|
|
return jsonResponse(200, { ok: true });
|
|
}
|
|
if (ctx.method === "GET" && ctx.pathname === "/example/invoices") {
|
|
const deny = rbacCheck(ctx.capabilities, "example:read");
|
|
if (deny !== null) return deny;
|
|
return jsonResponse(200, { invoices: listInvoices(ctx.store) });
|
|
}
|
|
if (ctx.method === "GET") {
|
|
const id = matchInvoiceId(ctx.pathname);
|
|
if (id !== null) {
|
|
const deny = rbacCheck(ctx.capabilities, "example:read");
|
|
if (deny !== null) return deny;
|
|
return jsonResponse(200, getInvoiceOrThrow(ctx.store, id));
|
|
}
|
|
}
|
|
if (ctx.method === "POST" && ctx.pathname === "/example/invoices") {
|
|
const deny = rbacCheck(ctx.capabilities, "example:write");
|
|
if (deny !== null) return deny;
|
|
const parsedBody = parseRequestBody(ctx.body);
|
|
const parsed = parseCreateInvoiceInput(parsedBody);
|
|
if (!parsed.success) {
|
|
return jsonResponse(400, {
|
|
code: "VALIDATION_FAILED",
|
|
issues: parsed.issues,
|
|
});
|
|
}
|
|
const created = createInvoice(ctx.store, parsed.data);
|
|
return jsonResponse(200, created);
|
|
}
|
|
if (ctx.method === "POST") {
|
|
const finaliseId = matchFinaliseId(ctx.pathname);
|
|
if (finaliseId !== null) {
|
|
const deny = rbacCheck(ctx.capabilities, "example:write");
|
|
if (deny !== null) return deny;
|
|
// The trusted tier awaits a Promise here to keep its
|
|
// Stripe-call histogram observation > 0 ms; the wasm tier
|
|
// has no histogram to record so the await is a no-op.
|
|
await Promise.resolve();
|
|
const updated = finaliseInvoice(ctx.store, finaliseId);
|
|
return jsonResponse(200, updated);
|
|
}
|
|
}
|
|
if (ctx.method === "GET" && ctx.pathname === "/example/rate-limited") {
|
|
return jsonResponse(200, { ok: true });
|
|
}
|
|
if (
|
|
ctx.method === "GET" &&
|
|
ctx.pathname === "/example/subscription-required"
|
|
) {
|
|
return raiseSubscriptionInactive();
|
|
}
|
|
if (ctx.method === "GET" && ctx.pathname === "/_/test/fail-response") {
|
|
const fail = ctx.search.get("failResponse") === "1";
|
|
return jsonResponse(200, buildFailResponsePayload(fail));
|
|
}
|
|
return jsonResponse(404, { code: "ROUTE_NOT_FOUND", path: ctx.pathname });
|
|
}
|
|
|
|
function matchInvoiceId(pathname: string): string | null {
|
|
const prefix = "/example/invoices/";
|
|
if (!pathname.startsWith(prefix)) return null;
|
|
const tail = pathname.slice(prefix.length);
|
|
if (tail.length === 0 || tail.includes("/")) return null;
|
|
return tail;
|
|
}
|
|
|
|
function matchFinaliseId(pathname: string): string | null {
|
|
const prefix = "/example/invoices/";
|
|
const suffix = "/finalise";
|
|
if (!pathname.startsWith(prefix) || !pathname.endsWith(suffix)) return null;
|
|
const id = pathname.slice(prefix.length, pathname.length - suffix.length);
|
|
if (id.length === 0 || id.includes("/")) return null;
|
|
return id;
|
|
}
|
|
|
|
function rbacCheck(
|
|
capabilities: readonly string[],
|
|
scope: string
|
|
): SerializedResponse | null {
|
|
if (capabilities.includes(scope)) return null;
|
|
return jsonResponse(403, {
|
|
code: "RBAC_MISSING_SCOPE",
|
|
requiredScope: scope,
|
|
actualScopes: capabilities,
|
|
});
|
|
}
|
|
|
|
function jsonResponse(status: number, payload: unknown): SerializedResponse {
|
|
const body = JSON.stringify(payload);
|
|
return {
|
|
status,
|
|
headers: [["content-type", "application/json"]],
|
|
bodyBase64: encodeBase64Url(textToBytes(body)),
|
|
};
|
|
}
|
|
|
|
/** Parse the request body bytes as JSON (or `undefined` when empty/invalid). */
|
|
function parseRequestBody(body: Uint8Array): unknown {
|
|
if (body.length === 0) return undefined;
|
|
const text = bytesToText(body);
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Decode base64-url-no-pad to raw bytes. */
|
|
function decodeBody(base64Url: string): Uint8Array {
|
|
if (base64Url.length === 0) return new Uint8Array(0);
|
|
const padLen = (4 - (base64Url.length % 4)) % 4;
|
|
const padded =
|
|
base64Url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(padLen);
|
|
const binary = atob(padded);
|
|
const out = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
out[i] = binary.charCodeAt(i);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Encode raw bytes as base64-url-no-pad. */
|
|
function encodeBase64Url(bytes: Uint8Array): string {
|
|
const codes: number[] = [];
|
|
for (const byte of bytes) {
|
|
codes.push(byte);
|
|
}
|
|
const binary = String.fromCharCode(...codes);
|
|
const b64 = btoa(binary);
|
|
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function textToBytes(text: string): Uint8Array {
|
|
return new TextEncoder().encode(text);
|
|
}
|
|
|
|
function bytesToText(bytes: Uint8Array): string {
|
|
return new TextDecoder().decode(bytes);
|
|
}
|