@kethosbase/functions
v0.1.0
Published
Official Kethosbase SDK for serverless Functions (QuickJS-on-WASM runtime): serve(), db, fetch, secrets, and pure-JS crypto. Zero third-party runtime dependencies.
Maintainers
Readme
@kethosbase/functions
The official SDK you import inside a Kethosbase serverless Function.
Functions run as a WebAssembly module: you write TypeScript, the Kethosbase CLI
compiles it to a QuickJS-on-WASM module (via its Javy toolchain), and the
platform runs it in a wazero sandbox. This is a different runtime from
@kethosbase/client. There is no fetch, no network, no Node or browser API
in here. The only way a Function reaches the platform is through host-provided
globals — and this SDK wraps them in an ergonomic API so you never touch them
directly.
- Zero third-party runtime dependencies.
- Pure-JS base64, UTF-8, SHA-256 and HMAC-SHA256 (the runtime has no Web Crypto), so signature verification works with no imports.
- Full TypeScript types.
npm install @kethosbase/functionsHello, Function
import { serve, kethosbase } from "@kethosbase/functions";
serve(async (req) => {
await kethosbase.log(`${req.method} ${req.path}`);
const { rows } = await kethosbase.db.query("select $1::int as n", [1]);
return {
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ n: rows[0].n }),
};
});serve(handler) is the whole program: it reads the incoming request from the
host, hands your handler an ergonomic request object, and writes the response
back. Register exactly one handler.
The request object
interface FunctionRequest {
method: string;
path: string;
query: string; // raw query string, no leading "?"
query_get(name: string): string | null; // first value, percent-decoded
headers: Record<string, string>; // keys lower-cased
body: Uint8Array; // decoded from the wire
text(): string; // body as UTF-8
json<T>(): T; // body parsed as JSON (throws if invalid)
}The response
Return { status?, headers?, body? }. body may be a string (UTF-8 encoded
for you) or a Uint8Array. Return nothing for an empty 200. status is
clamped to the valid 100..599 range.
If your handler throws (or rejects), the SDK returns a generic 500 and logs
the error detail for the operator — the error message is never put in the
HTTP response body.
The kethosbase object
Every method returns a Promise so you can await it. (The host bridge is
synchronous under QuickJS — there is no real async I/O in this runtime — but the
async surface keeps handlers ergonomic and future-proof.)
| Call | Does |
| --- | --- |
| kethosbase.log(msg) | Append a line to the Function log. |
| kethosbase.db.query(sql, args?) | Run parameterized SQL ($1, $2, …). Returns { rows }; throws on error. |
| kethosbase.fetch(url, init?) | Outbound HTTP through the host (the only egress path). Fetch-like. |
| kethosbase.secret(name) | Resolve a Function secret; throws if unset. |
Database
const { rows } = await kethosbase.db.query<{ id: string; total: number }>(
"select id, total from invoices where org_id = $1 order by created_at desc limit 10",
[orgId],
);Queries run with the Function's least-privilege, RLS-bound role — they are
never elevated to service_role, even if the Function was invoked with a secret
key (ADR-0084 / ADR-0091). Always use $1-style placeholders; never build SQL
by string concatenation.
Outbound HTTP
const res = await kethosbase.fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { authorization: `Bearer ${await kethosbase.secret("STRIPE_KEY")}` },
body: "amount=500¤cy=usd",
});
if (!res.ok) throw new Error(`stripe ${res.status}`);
const charge = res.json<{ id: string }>();Secrets
const secret = await kethosbase.secret("STRIPE_WEBHOOK_SECRET");Secrets are returned to your code only. The SDK never logs a secret value.
Crypto helpers
The runtime has no crypto.subtle, so the SDK ships pure-JS primitives for the
one thing Functions most need them for — verifying inbound webhook signatures:
import { hmacSha256, toHex, timingSafeEqual, utf8Encode } from "@kethosbase/functions";sha256(bytes) => Uint8ArrayhmacSha256(key, message) => Uint8Array(key/message:string | Uint8Array)toHex(bytes) => stringtimingSafeEqual(a, b) => boolean— constant-time byte compare
Full example: Stripe webhook
Verifies the Stripe-Signature header (scheme t=<ts>,v1=<hmac>), then records
the event. Stripe signs the string "<timestamp>.<raw-body>" with your webhook
signing secret using HMAC-SHA256.
import {
serve,
kethosbase,
hmacSha256,
toHex,
timingSafeEqual,
utf8Encode,
} from "@kethosbase/functions";
// Parse "t=123,v1=abc,v1=def" into { t, v1: [...] }.
function parseSigHeader(header: string): { t?: string; v1: string[] } {
const out: { t?: string; v1: string[] } = { v1: [] };
for (const part of header.split(",")) {
const [k, v] = part.split("=", 2);
if (k === "t") out.t = v;
else if (k === "v1" && v) out.v1.push(v);
}
return out;
}
serve(async (req) => {
const sigHeader = req.headers["stripe-signature"];
if (!sigHeader) return { status: 400, body: "missing signature" };
const { t, v1 } = parseSigHeader(sigHeader);
if (!t || v1.length === 0) return { status: 400, body: "malformed signature" };
// Reject stale timestamps to blunt replay (5-minute tolerance).
const ageSeconds = Math.floor(Date.now() / 1000) - Number(t);
if (!Number.isFinite(ageSeconds) || Math.abs(ageSeconds) > 300) {
return { status: 400, body: "timestamp outside tolerance" };
}
const secret = await kethosbase.secret("STRIPE_WEBHOOK_SECRET");
// Sign "<t>.<raw body>" — use the raw bytes, not a re-serialized JSON.
const signedPayload = `${t}.${req.text()}`;
const expected = toHex(hmacSha256(secret, signedPayload));
const expectedBytes = utf8Encode(expected);
// Constant-time compare against every provided v1 (Stripe may send several
// during secret rotation).
const ok = v1.some((candidate) =>
timingSafeEqual(expectedBytes, utf8Encode(candidate)),
);
if (!ok) return { status: 400, body: "signature mismatch" };
// Signature verified — record the event idempotently.
const event = req.json<{ id: string; type: string }>();
await kethosbase.db.query(
`insert into stripe_events (id, type, received_at)
values ($1, $2, now())
on conflict (id) do nothing`,
[event.id, event.type],
);
return { status: 200, headers: { "content-type": "application/json" }, body: "{}" };
});Notes
- We compare the hex digests as bytes with
timingSafeEqualso a mismatch never short-circuits and leaks how much matched.- The timestamp check bounds replay; the
on conflict do nothingmakes re-delivery idempotent.req.text()returns the exact received body, which is what Stripe signed — do not re-serialize the JSON before hashing, or the signature will not match.
Runtime notes
- Sync bridge, async API. The host globals are synchronous (QuickJS is single-threaded and there is no event loop with real timers/I/O). The public methods return Promises for ergonomics; they resolve on a microtask that the Javy runtime drains before the module returns.
- No
fetch,Buffer,atob, orcrypto.subtle. The SDK is written to the QuickJS baseline and feature-detectsTextEncoder/TextDecoder, so it works whether or not the runtime provides them.
License
MIT © Kerythos AI LLC.
