Initial Commit
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user