@nerdsystem/sdk 0.0.14 → 0.0.15
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/README.md +17 -0
- package/dist/access.d.ts +45 -0
- package/dist/access.js +76 -0
- package/dist/app.d.ts +6 -0
- package/dist/app.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/platform.d.ts +7 -0
- package/dist/platform.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,23 @@ export default app;
|
|
|
15
15
|
|
|
16
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`.
|
|
17
17
|
|
|
18
|
+
## Locked by default, and checked input
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createNerdApp, readBody } from "@nerdsystem/sdk";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
// Every route needs a signed-in user except the ones named here. Leave `public` out and routes are
|
|
25
|
+
// open unless each one says requireAuth(), which is how apps created before 0.0.15 behave.
|
|
26
|
+
const app = createNerdApp({ public: ["GET /api/health"] });
|
|
27
|
+
|
|
28
|
+
app.post("/api/notes", async (c) => {
|
|
29
|
+
// 400 in the usual error shape unless the body matches. Works with anything that has zod's safeParse.
|
|
30
|
+
const input = await readBody(c, z.object({ title: z.string().trim().min(1).max(200) }));
|
|
31
|
+
// ...
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
18
35
|
## Testing who can reach what
|
|
19
36
|
|
|
20
37
|
`@nerdsystem/sdk/testing` runs your app's real routes in-process, as a signed-out visitor and as made-up signed-in users. It is for tests only; never import it from server code.
|
package/dist/access.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import type { NerdEnv, NerdVariables } from "./env";
|
|
3
|
+
type Env<E extends NerdEnv> = {
|
|
4
|
+
Bindings: E;
|
|
5
|
+
Variables: NerdVariables;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Sign-in required for everything, unless the route is named. With this in front, a route the agent
|
|
9
|
+
* forgot to protect answers 401 instead of answering everyone: forgetting fails closed.
|
|
10
|
+
*
|
|
11
|
+
* Let through without a user: the SDK's own sign-in routes, preflight requests, the routes named in
|
|
12
|
+
* `publicRoutes` ("GET /api/health", with `:param` and `*` allowed), and a scheduled call that carries
|
|
13
|
+
* the platform's valid signature. Everything else needs `c.var.user`; a route can still ask for more
|
|
14
|
+
* with `requireRole`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function lockByDefault<E extends NerdEnv>(publicRoutes: readonly string[], authPath: string): MiddlewareHandler<Env<E>>;
|
|
17
|
+
/** Thrown by `readBody` and `readQuery`; `createNerdApp` answers it with 400 and the usual error shape. */
|
|
18
|
+
export declare class BadRequest extends Error {
|
|
19
|
+
readonly details?: unknown | undefined;
|
|
20
|
+
constructor(message: string, details?: unknown | undefined);
|
|
21
|
+
}
|
|
22
|
+
/** Anything with zod's `safeParse`: the SDK does not depend on a validation library, the app brings its own. */
|
|
23
|
+
export interface Schema<T> {
|
|
24
|
+
safeParse(input: unknown): {
|
|
25
|
+
success: true;
|
|
26
|
+
data: T;
|
|
27
|
+
} | {
|
|
28
|
+
success: false;
|
|
29
|
+
error: {
|
|
30
|
+
issues?: readonly {
|
|
31
|
+
path?: readonly (string | number | symbol)[];
|
|
32
|
+
message?: string;
|
|
33
|
+
}[];
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The request's JSON body, checked against a schema, or a 400. Use it instead of `c.req.json()` so every
|
|
39
|
+
* field has a type, a length, and a range before the route touches it:
|
|
40
|
+
* `const input = await readBody(c, z.object({ title: z.string().trim().min(1).max(200) }))`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readBody<T>(c: Context, schema: Schema<T>): Promise<T>;
|
|
43
|
+
/** The query string as an object, checked against a schema, or a 400. */
|
|
44
|
+
export declare function readQuery<T>(c: Context, schema: Schema<T>): T;
|
|
45
|
+
export {};
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { cronSignatureValid } from "./platform";
|
|
2
|
+
function matcher(route) {
|
|
3
|
+
const space = route.indexOf(" ");
|
|
4
|
+
if (space < 1)
|
|
5
|
+
throw new Error(`createNerdApp: public route "${route}" must look like "GET /api/health".`);
|
|
6
|
+
const method = route.slice(0, space).toUpperCase();
|
|
7
|
+
const path = route.slice(space + 1).trim();
|
|
8
|
+
if (!path.startsWith("/"))
|
|
9
|
+
throw new Error(`createNerdApp: public route "${route}" must look like "GET /api/health".`);
|
|
10
|
+
const src = path
|
|
11
|
+
.split("/")
|
|
12
|
+
.map((seg) => (seg.startsWith(":") ? "[^/]+" : seg === "*" ? ".*" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
|
13
|
+
.join("/");
|
|
14
|
+
return { method, re: new RegExp(`^${src}/?$`) };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Sign-in required for everything, unless the route is named. With this in front, a route the agent
|
|
18
|
+
* forgot to protect answers 401 instead of answering everyone: forgetting fails closed.
|
|
19
|
+
*
|
|
20
|
+
* Let through without a user: the SDK's own sign-in routes, preflight requests, the routes named in
|
|
21
|
+
* `publicRoutes` ("GET /api/health", with `:param` and `*` allowed), and a scheduled call that carries
|
|
22
|
+
* the platform's valid signature. Everything else needs `c.var.user`; a route can still ask for more
|
|
23
|
+
* with `requireRole`.
|
|
24
|
+
*/
|
|
25
|
+
export function lockByDefault(publicRoutes, authPath) {
|
|
26
|
+
const open = publicRoutes.map(matcher);
|
|
27
|
+
const auth = authPath.replace(/\/$/, "");
|
|
28
|
+
return async (c, next) => {
|
|
29
|
+
if (c.get("user"))
|
|
30
|
+
return next();
|
|
31
|
+
const method = c.req.method === "HEAD" ? "GET" : c.req.method;
|
|
32
|
+
const path = c.req.path;
|
|
33
|
+
if (method === "OPTIONS" || path === auth || path.startsWith(`${auth}/`))
|
|
34
|
+
return next();
|
|
35
|
+
if (open.some((o) => o.method === method && o.re.test(path)))
|
|
36
|
+
return next();
|
|
37
|
+
if (await cronSignatureValid(c))
|
|
38
|
+
return next();
|
|
39
|
+
return c.json({ error: { code: "unauthorized", message: "Sign in required" } }, 401);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Thrown by `readBody` and `readQuery`; `createNerdApp` answers it with 400 and the usual error shape. */
|
|
43
|
+
export class BadRequest extends Error {
|
|
44
|
+
details;
|
|
45
|
+
constructor(message, details) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.details = details;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parse(schema, input, what) {
|
|
51
|
+
const r = schema.safeParse(input);
|
|
52
|
+
if (r.success)
|
|
53
|
+
return r.data;
|
|
54
|
+
const issues = (r.error.issues ?? []).slice(0, 10).map((i) => ({ field: (i.path ?? []).map(String).join("."), problem: String(i.message ?? "invalid") }));
|
|
55
|
+
const first = issues[0];
|
|
56
|
+
throw new BadRequest(first ? `${first.field || what}: ${first.problem}` : `The ${what} is not valid.`, { issues });
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The request's JSON body, checked against a schema, or a 400. Use it instead of `c.req.json()` so every
|
|
60
|
+
* field has a type, a length, and a range before the route touches it:
|
|
61
|
+
* `const input = await readBody(c, z.object({ title: z.string().trim().min(1).max(200) }))`.
|
|
62
|
+
*/
|
|
63
|
+
export async function readBody(c, schema) {
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = await c.req.json();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new BadRequest("The request body must be JSON.");
|
|
70
|
+
}
|
|
71
|
+
return parse(schema, raw, "body");
|
|
72
|
+
}
|
|
73
|
+
/** The query string as an object, checked against a schema, or a 400. */
|
|
74
|
+
export function readQuery(c, schema) {
|
|
75
|
+
return parse(schema, c.req.query(), "query");
|
|
76
|
+
}
|
package/dist/app.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface CreateNerdAppOptions {
|
|
|
9
9
|
securityHeaders?: SecurityHeadersOptions | false;
|
|
10
10
|
/** Reject cookie-authenticated writes that a browser sent from another site. Pass false only if you handle CSRF yourself. */
|
|
11
11
|
crossSiteGuard?: false;
|
|
12
|
+
/**
|
|
13
|
+
* Turns on sign-in for every route: only the routes named here answer a signed-out visitor, e.g.
|
|
14
|
+
* `["GET /api/health"]` (`:param` and `*` work). Pass `[]` to lock everything. Leave it out and routes
|
|
15
|
+
* are open unless each one says `requireAuth()`, which is how apps created before this option behave.
|
|
16
|
+
*/
|
|
17
|
+
public?: readonly string[];
|
|
12
18
|
}
|
|
13
19
|
/**
|
|
14
20
|
* A Hono app with nerdsystem auth wired in: session routes at /api/auth and `c.var.user`
|
package/dist/app.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { attachUser } from "./auth/middleware";
|
|
3
3
|
import { crossSiteGuard } from "./auth/csrf";
|
|
4
4
|
import { authRoutes } from "./auth/routes";
|
|
5
|
+
import { BadRequest, lockByDefault } from "./access";
|
|
5
6
|
import { securityHeaders } from "./security";
|
|
6
7
|
/**
|
|
7
8
|
* A Hono app with nerdsystem auth wired in: session routes at /api/auth and `c.var.user`
|
|
@@ -14,9 +15,13 @@ export function createNerdApp(options = {}) {
|
|
|
14
15
|
if (options.crossSiteGuard !== false)
|
|
15
16
|
app.use("*", crossSiteGuard());
|
|
16
17
|
app.use("*", attachUser());
|
|
18
|
+
if (options.public)
|
|
19
|
+
app.use("*", lockByDefault(options.public, options.authPath ?? "/api/auth"));
|
|
17
20
|
app.route(options.authPath ?? "/api/auth", authRoutes());
|
|
18
21
|
app.notFound((c) => c.json({ error: { code: "not_found", message: `No route for ${c.req.method} ${c.req.path}` } }, 404));
|
|
19
22
|
app.onError((err, c) => {
|
|
23
|
+
if (err instanceof BadRequest)
|
|
24
|
+
return c.json({ error: { code: "bad_request", message: err.message, ...(err.details !== undefined ? { details: err.details } : {}) } }, 400);
|
|
20
25
|
console.error("unhandled error", err);
|
|
21
26
|
return c.json({ error: { code: "internal", message: "Internal error" } }, 500);
|
|
22
27
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export { verifyAccessToken, type VerifiedToken } from "./auth/jwks";
|
|
|
7
7
|
export { authRoutes } from "./auth/routes";
|
|
8
8
|
export { securityHeaders, DEFAULT_CSP, type SecurityHeadersOptions } from "./security";
|
|
9
9
|
export { sendEmail, verifyCron, cronEvent, type CronEvent } from "./platform";
|
|
10
|
+
export { BadRequest, readBody, readQuery, type Schema } from "./access";
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,4 @@ export { verifyAccessToken } from "./auth/jwks";
|
|
|
6
6
|
export { authRoutes } from "./auth/routes";
|
|
7
7
|
export { securityHeaders, DEFAULT_CSP } from "./security";
|
|
8
8
|
export { sendEmail, verifyCron, cronEvent } from "./platform";
|
|
9
|
+
export { BadRequest, readBody, readQuery } from "./access";
|
package/dist/platform.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export declare function sendEmail(env: NerdEnv, message: {
|
|
|
16
16
|
export declare function verifyCron(): MiddlewareHandler<{
|
|
17
17
|
Bindings: NerdEnv;
|
|
18
18
|
}>;
|
|
19
|
+
/** True when the request carries the platform's fresh, valid scheduled-call signature. */
|
|
20
|
+
export declare function cronSignatureValid(c: {
|
|
21
|
+
req: {
|
|
22
|
+
header(name: string): string | undefined;
|
|
23
|
+
};
|
|
24
|
+
env: Pick<NerdEnv, "NERD_AUTH_SECRET" | "NERD_PROJECT_ID">;
|
|
25
|
+
}): Promise<boolean>;
|
|
19
26
|
/** Shape of the body the scheduler posts to /api/cron. */
|
|
20
27
|
export interface CronEvent {
|
|
21
28
|
schedule: "hourly" | "daily";
|
package/dist/platform.js
CHANGED
|
@@ -23,12 +23,20 @@ export function verifyCron() {
|
|
|
23
23
|
const sig = c.req.header("x-nerd-cron-signature") ?? "";
|
|
24
24
|
if (!ts || !sig || Math.abs(Date.now() / 1000 - Number(ts)) > 300)
|
|
25
25
|
return c.json({ error: { code: "unauthorized", message: "Not a scheduled call" } }, 401);
|
|
26
|
-
|
|
27
|
-
if (expected.length !== sig.length || !timingSafeEqual(expected, sig))
|
|
26
|
+
if (!(await cronSignatureValid(c)))
|
|
28
27
|
return c.json({ error: { code: "unauthorized", message: "Bad cron signature" } }, 401);
|
|
29
28
|
await next();
|
|
30
29
|
};
|
|
31
30
|
}
|
|
31
|
+
/** True when the request carries the platform's fresh, valid scheduled-call signature. */
|
|
32
|
+
export async function cronSignatureValid(c) {
|
|
33
|
+
const ts = c.req.header("x-nerd-cron-timestamp") ?? "";
|
|
34
|
+
const sig = c.req.header("x-nerd-cron-signature") ?? "";
|
|
35
|
+
if (!ts || !sig || !c.env.NERD_AUTH_SECRET || Math.abs(Date.now() / 1000 - Number(ts)) > 300)
|
|
36
|
+
return false;
|
|
37
|
+
const expected = await hmacHex(await sha256Hex(c.env.NERD_AUTH_SECRET), `${ts}.${c.env.NERD_PROJECT_ID}`);
|
|
38
|
+
return expected.length === sig.length && timingSafeEqual(expected, sig);
|
|
39
|
+
}
|
|
32
40
|
function timingSafeEqual(a, b) {
|
|
33
41
|
let d = 0;
|
|
34
42
|
for (let i = 0; i < a.length; i++)
|