WordInk GitHub

The credential relay

@wordink/server keeps your long-lived provider keys on your server. Each provider is handled the way its API allows:

The relay is a Web-standard (Request) => Promise<Response> handler with no framework dependencies, plus thin adapters for Cloudflare Workers and node:http.

npm install @wordink/server

Cloudflare Worker

// src/index.ts
import { createWorker } from "@wordink/server/cloudflare";

export default createWorker((env) => ({
  basePath: "/wordink",
  allowedOrigins: ["https://app.example.com"],
  authorize: (request) => isSignedIn(request, env), // required, see below
}));
npx wrangler secret put GROQ_API_KEY      # and/or OPENAI_API_KEY, DEEPGRAM_API_KEY
npx wrangler deploy

Node

import { createServer } from "node:http";
import { createNodeHandler } from "@wordink/server/node";

// Keys are read from GROQ_API_KEY / OPENAI_API_KEY / DEEPGRAM_API_KEY.
createServer(
  createNodeHandler({
    basePath: "/wordink",
    allowedOrigins: ["https://app.example.com"],
    authorize: (request) => isSignedIn(request),
  }),
).listen(8787);

The Node adapter takes a second argument, { trustProxy }. By default it replaces X-Forwarded-For and CF-Connecting-IP with the socket address, so callers can't pick their own rate-limit bucket, and it takes the relay's own origin from the socket (https on a TLS socket) and the Host header, ignoring X-Forwarded-Proto and X-Forwarded-Host. Set trustProxy: true only behind a proxy that overwrites all of those headers; the forwarded scheme and host then become the relay's origin.

Any Fetch API runtime

Deno, Bun, Hono, Next.js route handlers:

import { createRelay } from "@wordink/server";

const relay = createRelay({
  keys: { groq: process.env.GROQ_API_KEY, openai: process.env.OPENAI_API_KEY },
  authorize: (request) => isSignedIn(request),
});
// relay(request) returns a Response.

Connect the browser

Set the element's (or hook's) endpoint to the relay's base URL including basePath, for example endpoint="https://relay.example.com/wordink" or endpoint="/wordink" on the same origin. Relay requests are sent with credentials: "include", so your session cookie reaches authorize.

Cross-origin cookie deployments must list the app's origin in allowedOrigins. A request whose Origin header (including Origin: null) is neither the relay's own origin nor in allowedOrigins gets 403 {"error":"origin_not_allowed"} before authorize runs and without any upstream call. That stops another site from making a signed-in user's browser spend your quota with their cookie (CSRF). Requests with no Origin header (server-to-server, curl) still go to authorize. The relay's own origin comes from the request URL. Behind a TLS-terminating proxy, where the Node adapter's socket is plain http://, set trustProxy: true if the proxy sets X-Forwarded-Proto and X-Forwarded-Host, or list your public origin in allowedOrigins.

authorize is required

The relay fails closed. Until you pass authorize, every route returns 403 and the relay logs one setup error at startup. Anyone who can reach an open relay can spend your provider quota, and Origin and CORS checks are not authentication: any script outside a browser can send any Origin.

authorize(request) gets the incoming Request and returns (or resolves to) true to allow it. Anything else, including a thrown error, is a 403. Bind it to your app's own user session.

A session cookie checked by your app:

authorize: async (request) => {
  const cookie = request.headers.get("cookie");
  if (!cookie) return false;
  const res = await fetch("https://app.example.com/api/session", { headers: { cookie } });
  return res.ok;
},

A signed JWT (here with jose), rate-limited per user:

import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.SESSION_JWT_SECRET);

const relay = createRelay({
  keys: { deepgram: process.env.DEEPGRAM_API_KEY },
  authorize: async (request) => {
    const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
    if (!token) return false;
    try {
      const { payload } = await jwtVerify(token, secret, { audience: "wordink" });
      return typeof payload.sub === "string";
    } catch {
      return false;
    }
  },
  clientId: (request) => request.headers.get("authorization") ?? "anon", // limit per user, not per IP
});

Limits

Option Default
rateLimit { windowMs: 60000, max: 20, dailyMax: 1000 } max upstream calls per client per window; dailyMax upstream calls per UTC day across all clients. Over the limit: 429 with Retry-After
clientId CF-Connecting-IP, then the first X-Forwarded-For, then "anon" The rate-limit key. Prefer your user id
maxBodyBytes 2 MB (about 60 s of 16 kHz PCM16) Larger Groq uploads get 413 before any upstream call
tokenTtlSeconds 120 Lifetime of minted OpenAI and Deepgram credentials. Fixed on the server; any TTL the client sends is ignored
upstreamTimeoutMs 15000 An upstream provider call that takes longer is aborted and answers 502

The built-in limiter is in memory: per process on Node, per isolate on Workers. For a hard global limit across instances, pass rateLimit: (clientId, request) => boolean | Promise<boolean> backed by KV, Redis or a Durable Object.

Routes and status codes

All routes are POST, under basePath.

Route Upstream Returns
/groq/transcriptions Groq audio/transcriptions (multipart body forwarded as is) Groq's transcription
/openai/token OpenAI realtime/client_secrets { "value": "ek_…", "expires_at": … }
/deepgram/token Deepgram auth/grant { "access_token": "…", "expires_in": 120 }

403: not authorized, no authorize hook, or an Origin that isn't allowed. 413: upload too large. 429: rate limited, by the relay or upstream. 502: upstream failed or timed out (its error body is never passed through, because provider errors can quote part of the key). 503: no key configured for that route.

What the relay logs

Nothing from request or response bodies (audio, transcripts, tokens), and never keys: only a setup error, upstream HTTP status codes and error names. If you wrap it in your own middleware, keep it that way; see privacy.