@nerdsystem/sdk 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/app.d.ts +15 -0
- package/dist/app.js +21 -0
- package/dist/auth/client.d.ts +81 -0
- package/dist/auth/client.js +76 -0
- package/dist/auth/cookies.d.ts +8 -0
- package/dist/auth/cookies.js +35 -0
- package/dist/auth/jwks.d.ts +10 -0
- package/dist/auth/jwks.js +44 -0
- package/dist/auth/middleware.d.ts +16 -0
- package/dist/auth/middleware.js +45 -0
- package/dist/auth/routes.d.ts +8 -0
- package/dist/auth/routes.js +119 -0
- package/dist/env.d.ts +28 -0
- package/dist/env.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/platform.d.ts +24 -0
- package/dist/platform.js +40 -0
- package/dist/security.d.ts +15 -0
- package/dist/security.js +38 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nerdsystem
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nerdsystem/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Runtime for apps deployed on [nerdsystem](https://nerdsystem.com): sign-in, roles, and platform bindings, so your code reads the signed-in user and never handles passwords or tokens.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createNerdApp, requireAuth, requireRole } from "@nerdsystem/sdk";
|
|
7
|
+
|
|
8
|
+
const app = createNerdApp();
|
|
9
|
+
app.get("/api/me", requireAuth(), (c) => c.json({ user: c.var.user }));
|
|
10
|
+
app.get("/api/admin", requireRole("admin"), (c) => c.json({ ok: true }));
|
|
11
|
+
export default app;
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`createNerdApp()` mounts session routes at `/api/auth` (signup, login, logout, me, forgot, reset), sets security headers on every response, and populates `c.var.user` from the httpOnly session cookie. Tokens are verified offline against the platform's JWKS.
|
|
15
|
+
|
|
16
|
+
Start from the template with `npx nerdsystem init my-app`. Documentation lives in the template's `AGENTS.md` and `.claude/skills/nerdsystem/SKILL.md`.
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { type NerdHonoEnv } from "./auth/middleware";
|
|
3
|
+
import type { NerdEnv } from "./env";
|
|
4
|
+
import { type SecurityHeadersOptions } from "./security";
|
|
5
|
+
export interface CreateNerdAppOptions {
|
|
6
|
+
/** Where session routes mount. Default "/api/auth". */
|
|
7
|
+
authPath?: string;
|
|
8
|
+
/** Security headers on every response. Pass false to disable, or options to tune the CSP. */
|
|
9
|
+
securityHeaders?: SecurityHeadersOptions | false;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A Hono app with nerdsystem auth wired in: session routes at /api/auth and `c.var.user`
|
|
13
|
+
* populated on every request. Add your API routes to it; static assets are served by the platform.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createNerdApp<E extends NerdEnv = NerdEnv>(options?: CreateNerdAppOptions): Hono<NerdHonoEnv<E>>;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { attachUser } from "./auth/middleware";
|
|
3
|
+
import { authRoutes } from "./auth/routes";
|
|
4
|
+
import { securityHeaders } from "./security";
|
|
5
|
+
/**
|
|
6
|
+
* A Hono app with nerdsystem auth wired in: session routes at /api/auth and `c.var.user`
|
|
7
|
+
* populated on every request. Add your API routes to it; static assets are served by the platform.
|
|
8
|
+
*/
|
|
9
|
+
export function createNerdApp(options = {}) {
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
if (options.securityHeaders !== false)
|
|
12
|
+
app.use("*", securityHeaders(options.securityHeaders ?? {}));
|
|
13
|
+
app.use("*", attachUser());
|
|
14
|
+
app.route(options.authPath ?? "/api/auth", authRoutes());
|
|
15
|
+
app.notFound((c) => c.json({ error: { code: "not_found", message: `No route for ${c.req.method} ${c.req.path}` } }, 404));
|
|
16
|
+
app.onError((err, c) => {
|
|
17
|
+
console.error("unhandled error", err);
|
|
18
|
+
return c.json({ error: { code: "internal", message: "Internal error" } }, 500);
|
|
19
|
+
});
|
|
20
|
+
return app;
|
|
21
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { NerdEnv, NerdUser } from "../env";
|
|
2
|
+
export interface Session {
|
|
3
|
+
user: EndUser;
|
|
4
|
+
accessToken: string;
|
|
5
|
+
expiresIn: number;
|
|
6
|
+
refreshToken: string;
|
|
7
|
+
}
|
|
8
|
+
export interface EndUser extends NerdUser {
|
|
9
|
+
emailVerifiedAt: string | null;
|
|
10
|
+
disabledAt: string | null;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class AuthServiceError extends Error {
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly details?: unknown | undefined;
|
|
18
|
+
constructor(status: number, code: string, message: string, details?: unknown | undefined);
|
|
19
|
+
}
|
|
20
|
+
/** Thin typed client for the platform auth service, scoped to this app's tenant. */
|
|
21
|
+
export declare class AuthServiceClient {
|
|
22
|
+
private readonly env;
|
|
23
|
+
private readonly fetchImpl;
|
|
24
|
+
private readonly base;
|
|
25
|
+
constructor(env: Pick<NerdEnv, "NERD_AUTH_URL" | "NERD_PROJECT_ID" | "NERD_AUTH_SECRET">, fetchImpl?: typeof fetch);
|
|
26
|
+
get jwksUrl(): string;
|
|
27
|
+
private call;
|
|
28
|
+
signup(input: {
|
|
29
|
+
email: string;
|
|
30
|
+
password: string;
|
|
31
|
+
name: string;
|
|
32
|
+
}): Promise<Session>;
|
|
33
|
+
login(input: {
|
|
34
|
+
email: string;
|
|
35
|
+
password: string;
|
|
36
|
+
}): Promise<Session>;
|
|
37
|
+
refresh(refreshToken: string): Promise<Session>;
|
|
38
|
+
logout(refreshToken: string): Promise<{
|
|
39
|
+
ok: true;
|
|
40
|
+
}>;
|
|
41
|
+
me(accessToken: string): Promise<{
|
|
42
|
+
user: EndUser;
|
|
43
|
+
}>;
|
|
44
|
+
forgotPassword(input: {
|
|
45
|
+
email: string;
|
|
46
|
+
resetUrl: string;
|
|
47
|
+
appName?: string;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
ok: true;
|
|
50
|
+
}>;
|
|
51
|
+
resetPassword(input: {
|
|
52
|
+
token: string;
|
|
53
|
+
password: string;
|
|
54
|
+
}): Promise<Session>;
|
|
55
|
+
/** Platform email relay. Server-side only. */
|
|
56
|
+
sendEmail(input: {
|
|
57
|
+
to: string;
|
|
58
|
+
subject: string;
|
|
59
|
+
text: string;
|
|
60
|
+
html?: string;
|
|
61
|
+
fromName?: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
sent: true;
|
|
64
|
+
remainingToday: number;
|
|
65
|
+
}>;
|
|
66
|
+
/** Admin operations, authenticated with the tenant secret. Server-side only. */
|
|
67
|
+
readonly users: {
|
|
68
|
+
list: (limit?: number) => Promise<{
|
|
69
|
+
users: EndUser[];
|
|
70
|
+
}>;
|
|
71
|
+
setRoles: (userId: string, roles: string[]) => Promise<{
|
|
72
|
+
user: EndUser;
|
|
73
|
+
}>;
|
|
74
|
+
setDisabled: (userId: string, disabled: boolean) => Promise<{
|
|
75
|
+
user: EndUser;
|
|
76
|
+
}>;
|
|
77
|
+
delete: (userId: string) => Promise<{
|
|
78
|
+
deleted: true;
|
|
79
|
+
}>;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export class AuthServiceError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(status, code, message, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.details = details;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Thin typed client for the platform auth service, scoped to this app's tenant. */
|
|
13
|
+
export class AuthServiceClient {
|
|
14
|
+
env;
|
|
15
|
+
fetchImpl;
|
|
16
|
+
base;
|
|
17
|
+
constructor(env,
|
|
18
|
+
// Wrapped, not bare: calling a stored `fetch` as a method breaks in workerd (illegal invocation).
|
|
19
|
+
fetchImpl = (input, init) => fetch(input, init)) {
|
|
20
|
+
this.env = env;
|
|
21
|
+
this.fetchImpl = fetchImpl;
|
|
22
|
+
this.base = `${env.NERD_AUTH_URL.replace(/\/$/, "")}/t/${env.NERD_PROJECT_ID}`;
|
|
23
|
+
}
|
|
24
|
+
get jwksUrl() {
|
|
25
|
+
return `${this.env.NERD_AUTH_URL.replace(/\/$/, "")}/.well-known/jwks.json`;
|
|
26
|
+
}
|
|
27
|
+
async call(method, path, body, auth) {
|
|
28
|
+
const res = await this.fetchImpl(`${this.base}${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
32
|
+
...(auth ? { authorization: `Bearer ${auth}` } : {}),
|
|
33
|
+
},
|
|
34
|
+
body: body !== undefined ? JSON.stringify(body) : null,
|
|
35
|
+
});
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
const json = text ? JSON.parse(text) : {};
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const err = json.error;
|
|
40
|
+
throw new AuthServiceError(res.status, err?.code ?? "auth_error", err?.message ?? `Auth service returned ${res.status}`, err?.details);
|
|
41
|
+
}
|
|
42
|
+
return json;
|
|
43
|
+
}
|
|
44
|
+
signup(input) {
|
|
45
|
+
return this.call("POST", "/signup", input);
|
|
46
|
+
}
|
|
47
|
+
login(input) {
|
|
48
|
+
return this.call("POST", "/login", input);
|
|
49
|
+
}
|
|
50
|
+
refresh(refreshToken) {
|
|
51
|
+
return this.call("POST", "/refresh", { refreshToken });
|
|
52
|
+
}
|
|
53
|
+
logout(refreshToken) {
|
|
54
|
+
return this.call("POST", "/logout", { refreshToken });
|
|
55
|
+
}
|
|
56
|
+
me(accessToken) {
|
|
57
|
+
return this.call("GET", "/me", undefined, accessToken);
|
|
58
|
+
}
|
|
59
|
+
forgotPassword(input) {
|
|
60
|
+
return this.call("POST", "/password/forgot", input);
|
|
61
|
+
}
|
|
62
|
+
resetPassword(input) {
|
|
63
|
+
return this.call("POST", "/password/reset", input);
|
|
64
|
+
}
|
|
65
|
+
/** Platform email relay. Server-side only. */
|
|
66
|
+
sendEmail(input) {
|
|
67
|
+
return this.call("POST", "/email", input, this.env.NERD_AUTH_SECRET);
|
|
68
|
+
}
|
|
69
|
+
/** Admin operations, authenticated with the tenant secret. Server-side only. */
|
|
70
|
+
users = {
|
|
71
|
+
list: (limit = 50) => this.call("GET", `/users?limit=${limit}`, undefined, this.env.NERD_AUTH_SECRET),
|
|
72
|
+
setRoles: (userId, roles) => this.call("PATCH", `/users/${userId}`, { roles }, this.env.NERD_AUTH_SECRET),
|
|
73
|
+
setDisabled: (userId, disabled) => this.call("PATCH", `/users/${userId}`, { disabled }, this.env.NERD_AUTH_SECRET),
|
|
74
|
+
delete: (userId) => this.call("DELETE", `/users/${userId}`, undefined, this.env.NERD_AUTH_SECRET),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type { Session } from "./client";
|
|
3
|
+
export declare function accessCookieName(c: Context): string;
|
|
4
|
+
export declare function refreshCookieName(c: Context): string;
|
|
5
|
+
export declare function setSessionCookies(c: Context, session: Session): void;
|
|
6
|
+
export declare function clearSessionCookies(c: Context): void;
|
|
7
|
+
export declare function readAccessCookie(c: Context): string | undefined;
|
|
8
|
+
export declare function readRefreshCookie(c: Context): string | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
2
|
+
/**
|
|
3
|
+
* Session cookies are host-only: no Domain attribute, and over https the `__Host-` prefix, which
|
|
4
|
+
* browsers only accept with Secure and Path=/ and never with a Domain. One app's cookies therefore
|
|
5
|
+
* cannot be read or overwritten by a sibling app on the shared app domain, nor by the platform.
|
|
6
|
+
*/
|
|
7
|
+
const ACCESS_BASE = "nerd_access";
|
|
8
|
+
const REFRESH_BASE = "nerd_refresh";
|
|
9
|
+
const REFRESH_MAX_AGE = 30 * 24 * 60 * 60;
|
|
10
|
+
function isSecure(c) {
|
|
11
|
+
const proto = c.req.header("x-forwarded-proto") ?? new URL(c.req.url).protocol.replace(":", "");
|
|
12
|
+
return proto === "https";
|
|
13
|
+
}
|
|
14
|
+
export function accessCookieName(c) {
|
|
15
|
+
return isSecure(c) ? `__Host-${ACCESS_BASE}` : ACCESS_BASE;
|
|
16
|
+
}
|
|
17
|
+
export function refreshCookieName(c) {
|
|
18
|
+
return isSecure(c) ? `__Host-${REFRESH_BASE}` : REFRESH_BASE;
|
|
19
|
+
}
|
|
20
|
+
export function setSessionCookies(c, session) {
|
|
21
|
+
const secure = isSecure(c);
|
|
22
|
+
setCookie(c, accessCookieName(c), session.accessToken, { httpOnly: true, secure, sameSite: "Lax", path: "/", maxAge: session.expiresIn });
|
|
23
|
+
setCookie(c, refreshCookieName(c), session.refreshToken, { httpOnly: true, secure, sameSite: "Lax", path: "/", maxAge: REFRESH_MAX_AGE });
|
|
24
|
+
}
|
|
25
|
+
export function clearSessionCookies(c) {
|
|
26
|
+
const secure = isSecure(c);
|
|
27
|
+
deleteCookie(c, accessCookieName(c), { path: "/", secure });
|
|
28
|
+
deleteCookie(c, refreshCookieName(c), { path: "/", secure });
|
|
29
|
+
}
|
|
30
|
+
export function readAccessCookie(c) {
|
|
31
|
+
return getCookie(c, accessCookieName(c));
|
|
32
|
+
}
|
|
33
|
+
export function readRefreshCookie(c) {
|
|
34
|
+
return getCookie(c, refreshCookieName(c));
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NerdUser } from "../env";
|
|
2
|
+
export interface VerifiedToken extends NerdUser {
|
|
3
|
+
exp: number;
|
|
4
|
+
}
|
|
5
|
+
/** Verifies an access token offline against the platform JWKS (cached per isolate). Returns null if invalid. */
|
|
6
|
+
export declare function verifyAccessToken(token: string, opts: {
|
|
7
|
+
jwksUrl: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
}): Promise<VerifiedToken | null>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { decode, verifyWithJwks } from "hono/jwt";
|
|
2
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
async function fetchKeys(jwksUrl, fetchImpl) {
|
|
5
|
+
const res = await fetchImpl(jwksUrl);
|
|
6
|
+
if (!res.ok)
|
|
7
|
+
throw new Error(`JWKS fetch failed: ${res.status}`);
|
|
8
|
+
const { keys } = (await res.json());
|
|
9
|
+
cache.set(jwksUrl, { keys, fetchedAt: Date.now() });
|
|
10
|
+
return keys;
|
|
11
|
+
}
|
|
12
|
+
async function keysFor(jwksUrl, kid, fetchImpl) {
|
|
13
|
+
const hit = cache.get(jwksUrl);
|
|
14
|
+
const fresh = hit && Date.now() - hit.fetchedAt < CACHE_TTL_MS;
|
|
15
|
+
if (hit && fresh && (!kid || hit.keys.some((k) => k.kid === kid)))
|
|
16
|
+
return hit.keys;
|
|
17
|
+
// Stale, or an unknown kid (key rotation): refetch.
|
|
18
|
+
return fetchKeys(jwksUrl, fetchImpl);
|
|
19
|
+
}
|
|
20
|
+
/** Verifies an access token offline against the platform JWKS (cached per isolate). Returns null if invalid. */
|
|
21
|
+
export async function verifyAccessToken(token, opts) {
|
|
22
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
23
|
+
let kid;
|
|
24
|
+
try {
|
|
25
|
+
kid = decode(token).header.kid;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const keys = await keysFor(opts.jwksUrl, kid, fetchImpl);
|
|
32
|
+
const claims = await verifyWithJwks(token, { keys, allowedAlgorithms: ["ES256"], verification: { aud: opts.projectId } });
|
|
33
|
+
return {
|
|
34
|
+
id: String(claims.sub),
|
|
35
|
+
email: String(claims.email),
|
|
36
|
+
name: String(claims.name),
|
|
37
|
+
roles: Array.isArray(claims.roles) ? claims.roles : [],
|
|
38
|
+
exp: Number(claims.exp),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import type { NerdEnv, NerdUser, NerdVariables } from "../env";
|
|
3
|
+
export type NerdHonoEnv<E extends NerdEnv = NerdEnv> = {
|
|
4
|
+
Bindings: E;
|
|
5
|
+
Variables: NerdVariables;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the current user from the access cookie or a bearer token and sets `c.var.user`.
|
|
9
|
+
* Never rejects: routes decide with `requireAuth` / `requireRole`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function attachUser<E extends NerdEnv>(): MiddlewareHandler<NerdHonoEnv<E>>;
|
|
12
|
+
export declare function resolveUser<E extends NerdEnv>(c: Context<NerdHonoEnv<E>>): Promise<NerdUser | null>;
|
|
13
|
+
/** 401 unless a user is signed in. */
|
|
14
|
+
export declare function requireAuth<E extends NerdEnv>(): MiddlewareHandler<NerdHonoEnv<E>>;
|
|
15
|
+
/** 401 if signed out, 403 unless the user has at least one of the given roles. */
|
|
16
|
+
export declare function requireRole<E extends NerdEnv>(...roles: string[]): MiddlewareHandler<NerdHonoEnv<E>>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { AuthServiceClient } from "./client";
|
|
2
|
+
import { readAccessCookie } from "./cookies";
|
|
3
|
+
import { verifyAccessToken } from "./jwks";
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the current user from the access cookie or a bearer token and sets `c.var.user`.
|
|
6
|
+
* Never rejects: routes decide with `requireAuth` / `requireRole`.
|
|
7
|
+
*/
|
|
8
|
+
export function attachUser() {
|
|
9
|
+
return async (c, next) => {
|
|
10
|
+
c.set("user", await resolveUser(c));
|
|
11
|
+
await next();
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function resolveUser(c) {
|
|
15
|
+
const bearer = c.req.header("authorization");
|
|
16
|
+
const token = bearer?.toLowerCase().startsWith("bearer ") ? bearer.slice(7) : readAccessCookie(c);
|
|
17
|
+
if (!token)
|
|
18
|
+
return null;
|
|
19
|
+
const client = new AuthServiceClient(c.env);
|
|
20
|
+
const verified = await verifyAccessToken(token, { jwksUrl: client.jwksUrl, projectId: c.env.NERD_PROJECT_ID });
|
|
21
|
+
if (!verified)
|
|
22
|
+
return null;
|
|
23
|
+
const { exp: _exp, ...user } = verified;
|
|
24
|
+
return user;
|
|
25
|
+
}
|
|
26
|
+
/** 401 unless a user is signed in. */
|
|
27
|
+
export function requireAuth() {
|
|
28
|
+
return async (c, next) => {
|
|
29
|
+
if (!c.get("user"))
|
|
30
|
+
return c.json({ error: { code: "unauthorized", message: "Sign in required" } }, 401);
|
|
31
|
+
await next();
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** 401 if signed out, 403 unless the user has at least one of the given roles. */
|
|
35
|
+
export function requireRole(...roles) {
|
|
36
|
+
return async (c, next) => {
|
|
37
|
+
const user = c.get("user");
|
|
38
|
+
if (!user)
|
|
39
|
+
return c.json({ error: { code: "unauthorized", message: "Sign in required" } }, 401);
|
|
40
|
+
if (!roles.some((r) => user.roles.includes(r))) {
|
|
41
|
+
return c.json({ error: { code: "forbidden", message: `Requires role: ${roles.join(" or ")}` } }, 403);
|
|
42
|
+
}
|
|
43
|
+
await next();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { NerdEnv } from "../env";
|
|
3
|
+
import type { NerdHonoEnv } from "./middleware";
|
|
4
|
+
/**
|
|
5
|
+
* Session routes the SDK mounts at /api/auth. Tokens live in httpOnly cookies on the app's own
|
|
6
|
+
* origin, so the browser never sees them. The frontend only ever calls these endpoints.
|
|
7
|
+
*/
|
|
8
|
+
export declare function authRoutes<E extends NerdEnv>(): Hono<NerdHonoEnv<E>>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { AuthServiceClient, AuthServiceError } from "./client";
|
|
3
|
+
import { clearSessionCookies, readAccessCookie, readRefreshCookie, setSessionCookies } from "./cookies";
|
|
4
|
+
/** Verifies a Turnstile token with Cloudflare. Only enforced when the app has a secret configured. */
|
|
5
|
+
async function verifyTurnstile(secret, token, ip) {
|
|
6
|
+
if (!token)
|
|
7
|
+
return false;
|
|
8
|
+
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "content-type": "application/json" },
|
|
11
|
+
body: JSON.stringify({ secret, response: token, ...(ip ? { remoteip: ip } : {}) }),
|
|
12
|
+
});
|
|
13
|
+
const json = (await res.json().catch(() => ({})));
|
|
14
|
+
return json.success === true;
|
|
15
|
+
}
|
|
16
|
+
function str(v) {
|
|
17
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Session routes the SDK mounts at /api/auth. Tokens live in httpOnly cookies on the app's own
|
|
21
|
+
* origin, so the browser never sees them. The frontend only ever calls these endpoints.
|
|
22
|
+
*/
|
|
23
|
+
export function authRoutes() {
|
|
24
|
+
const r = new Hono();
|
|
25
|
+
r.onError((err, c) => {
|
|
26
|
+
if (err instanceof AuthServiceError) {
|
|
27
|
+
return c.json({ error: { code: err.code, message: err.message, details: err.details } }, err.status);
|
|
28
|
+
}
|
|
29
|
+
console.error("auth route error", err);
|
|
30
|
+
return c.json({ error: { code: "internal", message: "Internal error" } }, 500);
|
|
31
|
+
});
|
|
32
|
+
r.post("/signup", async (c) => {
|
|
33
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
34
|
+
const email = str(body.email);
|
|
35
|
+
const password = str(body.password);
|
|
36
|
+
const name = str(body.name);
|
|
37
|
+
if (!email || !password || !name)
|
|
38
|
+
return c.json({ error: { code: "bad_request", message: "email, password, and name are required" } }, 400);
|
|
39
|
+
if (c.env.NERD_TURNSTILE_SECRET && !(await verifyTurnstile(c.env.NERD_TURNSTILE_SECRET, str(body.turnstileToken), c.req.header("cf-connecting-ip")))) {
|
|
40
|
+
return c.json({ error: { code: "bad_request", message: "Please complete the verification challenge" } }, 400);
|
|
41
|
+
}
|
|
42
|
+
const session = await new AuthServiceClient(c.env).signup({ email, password, name });
|
|
43
|
+
setSessionCookies(c, session);
|
|
44
|
+
return c.json({ user: session.user }, 201);
|
|
45
|
+
});
|
|
46
|
+
r.post("/login", async (c) => {
|
|
47
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
48
|
+
const email = str(body.email);
|
|
49
|
+
const password = str(body.password);
|
|
50
|
+
if (!email || !password)
|
|
51
|
+
return c.json({ error: { code: "bad_request", message: "email and password are required" } }, 400);
|
|
52
|
+
const session = await new AuthServiceClient(c.env).login({ email, password });
|
|
53
|
+
setSessionCookies(c, session);
|
|
54
|
+
return c.json({ user: session.user }, 200);
|
|
55
|
+
});
|
|
56
|
+
r.post("/refresh", async (c) => {
|
|
57
|
+
const refreshToken = readRefreshCookie(c);
|
|
58
|
+
if (!refreshToken)
|
|
59
|
+
return c.json({ error: { code: "unauthorized", message: "No session" } }, 401);
|
|
60
|
+
try {
|
|
61
|
+
const session = await new AuthServiceClient(c.env).refresh(refreshToken);
|
|
62
|
+
setSessionCookies(c, session);
|
|
63
|
+
return c.json({ user: session.user }, 200);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof AuthServiceError && err.status === 401) {
|
|
67
|
+
clearSessionCookies(c);
|
|
68
|
+
return c.json({ error: { code: "unauthorized", message: "Session expired" } }, 401);
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
r.post("/logout", async (c) => {
|
|
74
|
+
const refreshToken = readRefreshCookie(c);
|
|
75
|
+
if (refreshToken)
|
|
76
|
+
await new AuthServiceClient(c.env).logout(refreshToken).catch(() => { });
|
|
77
|
+
clearSessionCookies(c);
|
|
78
|
+
return c.json({ ok: true }, 200);
|
|
79
|
+
});
|
|
80
|
+
// Password reset. The email links to <app origin>/reset-password?token=..., a page the client renders.
|
|
81
|
+
r.post("/forgot", async (c) => {
|
|
82
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
83
|
+
const email = str(body.email);
|
|
84
|
+
if (!email)
|
|
85
|
+
return c.json({ error: { code: "bad_request", message: "email is required" } }, 400);
|
|
86
|
+
const origin = new URL(c.req.url).origin;
|
|
87
|
+
await new AuthServiceClient(c.env).forgotPassword({ email, resetUrl: `${origin}/reset-password`, ...(c.env.NERD_APP_NAME ? { appName: c.env.NERD_APP_NAME } : {}) });
|
|
88
|
+
return c.json({ ok: true }, 200);
|
|
89
|
+
});
|
|
90
|
+
r.post("/reset", async (c) => {
|
|
91
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
92
|
+
const token = str(body.token);
|
|
93
|
+
const password = str(body.password);
|
|
94
|
+
if (!token || !password)
|
|
95
|
+
return c.json({ error: { code: "bad_request", message: "token and password are required" } }, 400);
|
|
96
|
+
const session = await new AuthServiceClient(c.env).resetPassword({ token, password });
|
|
97
|
+
setSessionCookies(c, session);
|
|
98
|
+
return c.json({ user: session.user }, 200);
|
|
99
|
+
});
|
|
100
|
+
r.get("/me", async (c) => {
|
|
101
|
+
const user = c.get("user");
|
|
102
|
+
if (user)
|
|
103
|
+
return c.json({ user }, 200);
|
|
104
|
+
// Access cookie missing or expired. Try the refresh cookie once so page loads stay signed in.
|
|
105
|
+
const refreshToken = readRefreshCookie(c);
|
|
106
|
+
if (!refreshToken || readAccessCookie(c))
|
|
107
|
+
return c.json({ user: null }, 200);
|
|
108
|
+
try {
|
|
109
|
+
const session = await new AuthServiceClient(c.env).refresh(refreshToken);
|
|
110
|
+
setSessionCookies(c, session);
|
|
111
|
+
return c.json({ user: { id: session.user.id, email: session.user.email, name: session.user.name, roles: session.user.roles } }, 200);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
clearSessionCookies(c);
|
|
115
|
+
return c.json({ user: null }, 200);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return r;
|
|
119
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Bindings every nerdsystem app receives. Extend with your own via `createNerdApp<MyEnv>()`. */
|
|
2
|
+
export interface NerdEnv {
|
|
3
|
+
/** The app's own D1 database. */
|
|
4
|
+
DB: D1Database;
|
|
5
|
+
/** The app's own R2 bucket. */
|
|
6
|
+
STORAGE: R2Bucket;
|
|
7
|
+
/** Static assets built by Vite. Present in production and under `vite dev`. */
|
|
8
|
+
ASSETS: Fetcher;
|
|
9
|
+
NERD_PROJECT_ID: string;
|
|
10
|
+
/** Base URL of the auth service, e.g. https://api.nerdsystem.com/auth/v1 */
|
|
11
|
+
NERD_AUTH_URL: string;
|
|
12
|
+
/** Tenant secret for admin calls. Rotated by the platform on every deploy. */
|
|
13
|
+
NERD_AUTH_SECRET: string;
|
|
14
|
+
/** Display name used in emails to end users. Optional. */
|
|
15
|
+
NERD_APP_NAME?: string;
|
|
16
|
+
/** Cloudflare Turnstile secret. When set, signup requires a valid widget token. */
|
|
17
|
+
NERD_TURNSTILE_SECRET?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface NerdUser {
|
|
20
|
+
id: string;
|
|
21
|
+
email: string;
|
|
22
|
+
name: string;
|
|
23
|
+
roles: string[];
|
|
24
|
+
}
|
|
25
|
+
export type NerdVariables = {
|
|
26
|
+
/** The signed-in user, or null. Set by the SDK on every request. */
|
|
27
|
+
user: NerdUser | null;
|
|
28
|
+
};
|
package/dist/env.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createNerdApp, type CreateNerdAppOptions } from "./app";
|
|
2
|
+
export type { NerdEnv, NerdUser, NerdVariables } from "./env";
|
|
3
|
+
export { attachUser, requireAuth, requireRole, resolveUser, type NerdHonoEnv } from "./auth/middleware";
|
|
4
|
+
export { AuthServiceClient, AuthServiceError, type EndUser, type Session } from "./auth/client";
|
|
5
|
+
export { verifyAccessToken, type VerifiedToken } from "./auth/jwks";
|
|
6
|
+
export { authRoutes } from "./auth/routes";
|
|
7
|
+
export { securityHeaders, DEFAULT_CSP, type SecurityHeadersOptions } from "./security";
|
|
8
|
+
export { sendEmail, verifyCron, cronEvent, type CronEvent } from "./platform";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createNerdApp } from "./app";
|
|
2
|
+
export { attachUser, requireAuth, requireRole, resolveUser } from "./auth/middleware";
|
|
3
|
+
export { AuthServiceClient, AuthServiceError } from "./auth/client";
|
|
4
|
+
export { verifyAccessToken } from "./auth/jwks";
|
|
5
|
+
export { authRoutes } from "./auth/routes";
|
|
6
|
+
export { securityHeaders, DEFAULT_CSP } from "./security";
|
|
7
|
+
export { sendEmail, verifyCron, cronEvent } from "./platform";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import type { NerdEnv } from "./env";
|
|
3
|
+
/** Send a transactional email from the app through the platform relay (plan-limited per day). */
|
|
4
|
+
export declare function sendEmail(env: NerdEnv, message: {
|
|
5
|
+
to: string;
|
|
6
|
+
subject: string;
|
|
7
|
+
text: string;
|
|
8
|
+
html?: string;
|
|
9
|
+
}): Promise<{
|
|
10
|
+
remainingToday: number;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Verifies that a POST /api/cron came from the platform scheduler. The signature is an HMAC over
|
|
14
|
+
* "<timestamp>.<project id>" keyed by the SHA-256 of the app's tenant secret, valid for five minutes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function verifyCron(): MiddlewareHandler<{
|
|
17
|
+
Bindings: NerdEnv;
|
|
18
|
+
}>;
|
|
19
|
+
/** Shape of the body the scheduler posts to /api/cron. */
|
|
20
|
+
export interface CronEvent {
|
|
21
|
+
schedule: "hourly" | "daily";
|
|
22
|
+
at: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function cronEvent(c: Context): Promise<CronEvent>;
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AuthServiceClient } from "./auth/client";
|
|
2
|
+
/** Send a transactional email from the app through the platform relay (plan-limited per day). */
|
|
3
|
+
export async function sendEmail(env, message) {
|
|
4
|
+
const client = new AuthServiceClient(env);
|
|
5
|
+
const r = await client.sendEmail({ ...message, ...(env.NERD_APP_NAME ? { fromName: env.NERD_APP_NAME } : {}) });
|
|
6
|
+
return { remainingToday: r.remainingToday };
|
|
7
|
+
}
|
|
8
|
+
async function hmacHex(secret, data) {
|
|
9
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
10
|
+
return Array.from(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data))), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
11
|
+
}
|
|
12
|
+
async function sha256Hex(input) {
|
|
13
|
+
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
14
|
+
return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Verifies that a POST /api/cron came from the platform scheduler. The signature is an HMAC over
|
|
18
|
+
* "<timestamp>.<project id>" keyed by the SHA-256 of the app's tenant secret, valid for five minutes.
|
|
19
|
+
*/
|
|
20
|
+
export function verifyCron() {
|
|
21
|
+
return async (c, next) => {
|
|
22
|
+
const ts = c.req.header("x-nerd-cron-timestamp") ?? "";
|
|
23
|
+
const sig = c.req.header("x-nerd-cron-signature") ?? "";
|
|
24
|
+
if (!ts || !sig || Math.abs(Date.now() / 1000 - Number(ts)) > 300)
|
|
25
|
+
return c.json({ error: { code: "unauthorized", message: "Not a scheduled call" } }, 401);
|
|
26
|
+
const expected = await hmacHex(await sha256Hex(c.env.NERD_AUTH_SECRET), `${ts}.${c.env.NERD_PROJECT_ID}`);
|
|
27
|
+
if (expected.length !== sig.length || !timingSafeEqual(expected, sig))
|
|
28
|
+
return c.json({ error: { code: "unauthorized", message: "Bad cron signature" } }, 401);
|
|
29
|
+
await next();
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function timingSafeEqual(a, b) {
|
|
33
|
+
let d = 0;
|
|
34
|
+
for (let i = 0; i < a.length; i++)
|
|
35
|
+
d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
36
|
+
return d === 0;
|
|
37
|
+
}
|
|
38
|
+
export async function cronEvent(c) {
|
|
39
|
+
return (await c.req.json().catch(() => ({ schedule: "hourly", at: new Date().toISOString() })));
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
export interface SecurityHeadersOptions {
|
|
3
|
+
/** Content-Security-Policy. Pass a string to override, or false to omit. */
|
|
4
|
+
contentSecurityPolicy?: string | false;
|
|
5
|
+
/** Add HSTS on https responses. Default true. */
|
|
6
|
+
hsts?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A conservative default: same-origin scripts, styles may be inline (Vite and most UI libraries
|
|
10
|
+
* emit some), images from anywhere over https, API calls to the app's own origin, no framing.
|
|
11
|
+
* Apps that load third-party scripts override `contentSecurityPolicy`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_CSP: string;
|
|
14
|
+
/** Security headers on every response the Worker produces (static assets are served by the platform). */
|
|
15
|
+
export declare function securityHeaders(opts?: SecurityHeadersOptions): MiddlewareHandler;
|
package/dist/security.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A conservative default: same-origin scripts, styles may be inline (Vite and most UI libraries
|
|
3
|
+
* emit some), images from anywhere over https, API calls to the app's own origin, no framing.
|
|
4
|
+
* Apps that load third-party scripts override `contentSecurityPolicy`.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_CSP = [
|
|
7
|
+
"default-src 'self'",
|
|
8
|
+
"script-src 'self' https://challenges.cloudflare.com",
|
|
9
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
10
|
+
"font-src 'self' https://fonts.gstatic.com data:",
|
|
11
|
+
"img-src 'self' data: blob: https:",
|
|
12
|
+
"connect-src 'self'",
|
|
13
|
+
"frame-src https://challenges.cloudflare.com",
|
|
14
|
+
"frame-ancestors 'none'",
|
|
15
|
+
"base-uri 'self'",
|
|
16
|
+
"form-action 'self'",
|
|
17
|
+
].join("; ");
|
|
18
|
+
/** Security headers on every response the Worker produces (static assets are served by the platform). */
|
|
19
|
+
export function securityHeaders(opts = {}) {
|
|
20
|
+
const csp = opts.contentSecurityPolicy === undefined ? DEFAULT_CSP : opts.contentSecurityPolicy;
|
|
21
|
+
return async (c, next) => {
|
|
22
|
+
await next();
|
|
23
|
+
const h = c.res.headers;
|
|
24
|
+
if (!h.has("x-content-type-options"))
|
|
25
|
+
h.set("x-content-type-options", "nosniff");
|
|
26
|
+
if (!h.has("referrer-policy"))
|
|
27
|
+
h.set("referrer-policy", "strict-origin-when-cross-origin");
|
|
28
|
+
if (!h.has("permissions-policy"))
|
|
29
|
+
h.set("permissions-policy", "camera=(), microphone=(), geolocation=(), payment=()");
|
|
30
|
+
if (!h.has("x-frame-options"))
|
|
31
|
+
h.set("x-frame-options", "DENY");
|
|
32
|
+
if (csp && !h.has("content-security-policy"))
|
|
33
|
+
h.set("content-security-policy", csp);
|
|
34
|
+
if (opts.hsts !== false && c.req.url.startsWith("https://") && !h.has("strict-transport-security")) {
|
|
35
|
+
h.set("strict-transport-security", "max-age=31536000; includeSubDomains");
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerdsystem/sdk",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Runtime for apps deployed on nerdsystem: auth, authorization, and platform bindings.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"hono": "^4.13.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@cloudflare/workers-types": "^5.20261102.1",
|
|
24
|
+
"hono": "^4.13.16",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/doobe01/nerdsystem-cloudflare",
|
|
31
|
+
"directory": "packages/sdk"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://nerdsystem.com",
|
|
34
|
+
"keywords": [
|
|
35
|
+
"cloudflare",
|
|
36
|
+
"workers",
|
|
37
|
+
"hono",
|
|
38
|
+
"auth",
|
|
39
|
+
"deploy",
|
|
40
|
+
"claude-code"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
}
|
|
6
49
|
}
|