Skip to content

Node.js

@jiayang-cloud/sdk verifies the identity token the edge sends your app and gives you the caller. It runs anywhere with WebCrypto and fetch: Workers, Node 20 or newer, Deno and Bun.

Terminal window
npm install @jiayang-cloud/sdk

It ships ES modules and CommonJS, with TypeScript types. It has no runtime dependencies to install: the JWT library it uses is bundled in.

requireUser(request, env) takes a Request and the platform’s variables. On Workers those are the bindings your fetch handler receives.

import { requireUser, Unauthorized } from "@jiayang-cloud/sdk";
export default {
async fetch(request, env) {
try {
const user = await requireUser(request, env);
return new Response(`hello ${user.email ?? user.sub}`);
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
},
};

It returns a User:

interface User {
kind: "user" | "service";
sub: string; // "usr_…", or "service:<token id>" for a bypass token
email: string | null; // null for a bypass token
role: "viewer" | "editor" | "owner";
workspaceId: string;
}

See the SDK overview for what each field means and what the SDK checks.

import { Forbidden, hasRole, requireRole } from "@jiayang-cloud/sdk";
requireRole(user, "editor"); // throws Forbidden unless the caller is an editor or an owner
if (hasRole(user, "owner")) {
// show the settings link
}

Forbidden means the caller is known but may not do this. Answer it with err.toResponse(), which is a 403.

Each framework has its own entry point, so a Next.js app never loads the Express code.

Import For
@jiayang-cloud/sdk Workers, and anything that hands you a Request
@jiayang-cloud/sdk/next Next.js server components and route handlers
@jiayang-cloud/sdk/express Express, Connect, and Fastify’s Express plugin
@jiayang-cloud/sdk/hono Hono, on Workers, Node, Bun or Deno
@jiayang-cloud/sdk/node node:http, Koa, anything with Node-style headers

The /next and /express entries read the platform’s variables from process.env, unless you pass env yourself. /node reads nothing on its own: pass it envFromProcess(), as in the example below.

import { getUser, requireUser } from "@jiayang-cloud/sdk/next";
export default async function Page() {
const user = await getUser();
return <p>hello {user?.email ?? "stranger"}</p>;
}

Both read the request’s headers through next/headers. In a page or other server component, use getUser(): it returns null when there is no valid caller. requireUser() throws Unauthorized, and thrown from a server component that’s a 500, not a 401. Use it in a route handler, and answer with the error’s response:

app/api/me/route.ts
import { Unauthorized } from "@jiayang-cloud/sdk";
import { requireUser } from "@jiayang-cloud/sdk/next";
export async function GET() {
try {
const user = await requireUser();
return Response.json({ email: user.email });
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
}

Anywhere next/headers can’t be used, pass the request’s headers in: requireUser({ headers: request.headers }).

import { requireUser, withUser } from "@jiayang-cloud/sdk/express";
app.use(requireUser());
app.get("/", (req, res) => res.send(`hello ${req.user.email}`));
app.post("/orders", requireUser({ role: "editor" }), placeOrder);

requireUser() answers 401 unauthorized without a valid caller, and 403 forbidden: this needs editor when role is set and the caller has less. Neither reaches your handler. Otherwise it sets req.user.

withUser() lets everyone through and sets req.user when there is a caller, for a page that renders either way.

import { getUser, jiayang } from "@jiayang-cloud/sdk/hono";
app.use("*", jiayang());
app.get("/", (c) => c.text(`hello ${getUser(c).email}`));
app.post("/orders", jiayang({ role: "editor" }), placeOrder);

jiayang() answers 401 or 403 itself and puts the caller on the context. It reads the platform’s variables from Hono’s bindings on Workers, and from process.env on Node, Bun and Deno, so the same line works in a container.

getUser(c) throws if jiayang() didn’t run on that route.

import { createServer } from "node:http";
import { envFromProcess, requireUserFrom } from "@jiayang-cloud/sdk/node";
createServer(async (req, res) => {
try {
const user = await requireUserFrom(req, envFromProcess());
res.end(`hello ${user.email}\n`);
} catch {
res.statusCode = 401;
res.end("unauthorized\n");
}
}).listen(process.env.PORT);

requireUserFrom takes anything with Node-style headers, or the headers themselves. envFromProcess() reads the three variables from process.env.

A token sent twice is refused rather than one of them being picked. The edge sends exactly one, so two means something else added one. Node’s own server joins a repeated header into one value, so under node:http, Express and Koa two tokens fail as invalid identity token.

A delivery on a verified public path arrives with a token of kind webhook, not a person’s. requireWebhook checks it and says which provider signed the delivery. requireUser refuses a webhook’s token, and requireWebhook refuses a person’s.

import { requireWebhook, Unauthorized } from "@jiayang-cloud/sdk";
export default {
async fetch(request, env) {
if (new URL(request.url).pathname === "/hooks/stripe") {
try {
const hook = await requireWebhook(request, env, { provider: "stripe" });
const event = await request.json();
// hook.delivery is Stripe's event id. Skip one you've handled.
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
}
// requireUser for everything else
},
};

provider is required: stripe, github, slack, shopify, standard_webhooks or hmac_sha256, or a list of them, like { provider: ["github", "stripe"] }. A delivery from any other provider is refused, and so is every delivery when the list is empty. It returns a Webhook:

interface Webhook {
provider: "stripe" | "github" | "slack" | "shopify" | "standard_webhooks" | "hmac_sha256";
pattern: string; // the public path it came in on, like "/hooks/stripe"
delivery: string | null; // the provider's signed id, null where it signs none
signedAt: number | null; // unix seconds, null where the provider signs no time
workspaceId: string;
}

The body arrives byte for byte as the provider sent it, so request.json() or your framework’s body parser reads it as usual.

Mount the route before anything that checks every request for a person, which would refuse the delivery first.

import express from "express";
import { requireUser, requireWebhook } from "@jiayang-cloud/sdk/express";
app.post("/hooks/stripe", express.json(), requireWebhook({ provider: "stripe" }), (req, res) => {
// req.webhook, req.body
res.sendStatus(204);
});
app.use(requireUser());

requireWebhook() answers 401 unauthorized to anything but a verified delivery from the providers it names, and otherwise sets req.webhook.

import { getWebhook, jiayang, webhook } from "@jiayang-cloud/sdk/hono";
app.post("/hooks/stripe", webhook({ provider: "stripe" }), async (c) => {
const hook = getWebhook(c);
const event = await c.req.json();
return c.body(null, 204);
});
app.use("*", jiayang());

webhook() answers 401 itself, and reads the platform’s variables the way jiayang() does. getWebhook(c) throws if webhook() didn’t run on that route.

In a route handler, such as app/hooks/stripe/route.ts:

import { Unauthorized } from "@jiayang-cloud/sdk";
import { requireWebhook } from "@jiayang-cloud/sdk/next";
export async function POST(request: Request) {
try {
const hook = await requireWebhook({ provider: "stripe" });
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
const event = await request.json();
return new Response(null, { status: 204 });
}

It reads the headers through next/headers, or from headers if you pass it: requireWebhook({ provider: "stripe", headers: request.headers }).

import { envFromProcess, requireWebhookFrom } from "@jiayang-cloud/sdk/node";
const hook = await requireWebhookFrom(req, envFromProcess(), { provider: "github" });

Like requireUserFrom, it refuses a token sent twice.

verifyIdentity(token, env) runs the same checks on a token you took from somewhere else, such as a WebSocket message:

import { verifyIdentity } from "@jiayang-cloud/sdk";
const user = await verifyIdentity(token, env);

verifyWebhook(token, env, { provider: "stripe" }) does the same for a webhook’s token.

Every refusal is an Unauthorized (status 401) or a Forbidden (status 403). Both have .toResponse(), which answers unauthorized or forbidden: this needs editor as plain text with Cache-Control: no-store.

The message says why, for your logs:

err.message Why
no identity token The request has no X-Jiayang-Identity header
more than one identity token The headers given to /node or /express hold X-Jiayang-Identity as an array. Node’s own server never does this, so there two tokens give invalid identity token
invalid identity token The token failed a check
couldn't fetch the identity keys The key set couldn’t be fetched, and there’s no fresh copy
JIAYANG_APP_ID, JIAYANG_IDENTITY_ISSUER and JIAYANG_JWKS_URL must be set The variables didn’t reach the SDK
pass headers() in, or call this where next/headers can be imported /next was called where next/headers isn’t available
this needs editor A Forbidden from requireRole(user, "editor")
not a webhook this route takes From requireWebhook: a provider the route doesn’t name signed the delivery
invalid webhook token From requireWebhook: the token’s webhook claims failed a check
no provider named From requireWebhook: the call named no provider, so it refuses everything

A person’s token given to requireWebhook, or a webhook’s given to requireUser, fails as invalid identity token: its audience is the other kind’s.

Don’t send the message to the caller. The 401 or 403 is all they need.

jiayang dev puts the platform’s front door in front of your app on your machine, with real tokens. See Local development.

Terminal window
jiayang dev -- npm run dev