@server/next 0.20.9 → 0.20.10

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/index.d.ts CHANGED
@@ -15,6 +15,7 @@ type ServerOptions = {
15
15
  views?: string | Bucket;
16
16
  public?: string | Bucket;
17
17
  uploads?: string | Bucket;
18
+ auth?: string | { type: string; provider: string };
18
19
  };
19
20
 
20
21
  type Context = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.20.9",
3
+ "version": "0.20.10",
4
4
  "description": "An experimental reimplementation of server.js focused on the DX",
5
5
  "homepage": "https://node-server.com/",
6
6
  "repository": "https://github.com/franciscop/server-next.git",
@@ -9,7 +9,6 @@
9
9
  "author": "Francisco Presencia <public@francisco.io> (https://francisco.io/)",
10
10
  "license": "UNLICENSED",
11
11
  "scripts": {
12
- "demo": "nodemon ./demo/src/index.js",
13
12
  "start": "bun test --watch",
14
13
  "test": "bun test",
15
14
  "test:jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
package/readme.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  > **⚠️ WIP** This is an **experimental library** right now!
4
4
 
5
- [**Documentation Here**](https://node-server.com/documentation/)
6
-
7
5
  A fully-fledged web server for Bun and Node.js, with all the basics covered for you:
8
6
 
9
7
  ```js
@@ -26,11 +24,11 @@ It includes all the things you would expect from a modern Server framework, like
26
24
  ```js
27
25
  // Easy testing as well - index.test.js
28
26
  import app from "./";
27
+ const api = app.test(); // Very convenient helper, AXIOS-like interface
29
28
 
30
29
  it("can retrieve the homepage", async () => {
31
- const res = await app.fetch(new Request("http://localhost:3000/books/"));
32
- const books = await res.json();
33
- expect(books[0]).toEqual({ id: 0, name: 'The Catcher In The Rye', ... });
30
+ const { data: books } = await api.get("/books/");
31
+ expect(books[0]).toEqual({ id: 0, name: ... });
34
32
  });
35
33
  ```
36
34
 
@@ -56,6 +54,8 @@ Why? We live in the era of multi-cloud (Heroku, Workers, Lambda, etc) and multi-
56
54
  - A readStream and it'll be piped to the response
57
55
  - An object with `status`, `body` and `headers` and it'll be set raw.
58
56
  - Response compression works
57
+ - Zod light integration
58
+ - Auth work
59
59
 
60
60
  ## Examples
61
61
 
@@ -0,0 +1,18 @@
1
+ import { ServerError } from "../index.js";
2
+
3
+ class NoSession {}
4
+
5
+ export default new Proxy(NoSession, {
6
+ get(target, key) {
7
+ if (target[key]) return target[key];
8
+ if (key === "then") return target[key];
9
+ throw ServerError.NO_STORE_READ({ key });
10
+ },
11
+ set(target, key, value) {
12
+ if (target[key] || key === "then") {
13
+ target[key] = value;
14
+ } else {
15
+ throw ServerError.NO_STORE_WRITE({ key });
16
+ }
17
+ },
18
+ });
@@ -0,0 +1,41 @@
1
+ import "../test/toSucceed.js";
2
+
3
+ import kv from "polystore";
4
+
5
+ import server from "../index.js";
6
+
7
+ describe("user creation flow", () => {
8
+ // These are obviously mock data
9
+ const EMAIL = "abc@test.com";
10
+ const PASS = "11111111";
11
+ const CREDENTIALS = { email: EMAIL, password: PASS };
12
+
13
+ const store = kv(new Map());
14
+ const api = server({ store, auth: "cookie:email" })
15
+ .get("/me", (ctx) => ctx.user || "No data")
16
+ .test();
17
+
18
+ it("can create a new user", async () => {
19
+ const register = await api.post("/auth/register/email", CREDENTIALS);
20
+ expect(register).toSucceed();
21
+ expect(await store.keys()).toEqual([
22
+ "user:abc@test.com",
23
+ "auth:" + register.headers["set-cookie"].split("=")[1],
24
+ ]);
25
+
26
+ const me = await api.get("/me");
27
+ expect(me).toSucceed();
28
+ expect(me.data.email).toEqual(EMAIL);
29
+
30
+ const logout = await api.post("/auth/logout");
31
+ expect(logout).toSucceed();
32
+ expect(await store.keys()).toEqual(["user:abc@test.com"]);
33
+
34
+ const login = await api.post("/auth/login/email", CREDENTIALS);
35
+ expect(login).toSucceed();
36
+ expect(await store.keys()).toEqual([
37
+ "user:abc@test.com",
38
+ "auth:" + login.headers["set-cookie"].split("=")[1],
39
+ ]);
40
+ });
41
+ });
@@ -0,0 +1,42 @@
1
+ import "../test/toSucceed.js";
2
+
3
+ import kv from "polystore";
4
+
5
+ import server from "../index.js";
6
+
7
+ describe("user creation flow", () => {
8
+ // These are obviously mock data
9
+ const EMAIL = "abc@test.com";
10
+ const PASS = "11111111";
11
+ const CREDENTIALS = { email: EMAIL, password: PASS };
12
+
13
+ const store = kv(new Map());
14
+ const api = server({ store, auth: "token:email" })
15
+ .get("/me", (ctx) => ctx.user || "No data")
16
+ .test();
17
+
18
+ it("can create a new user", async () => {
19
+ const register = await api.post("/auth/register/email", CREDENTIALS);
20
+ expect(register).toSucceed();
21
+ expect(await store.keys()).toEqual([
22
+ "user:abc@test.com",
23
+ "auth:" + register.data.token,
24
+ ]);
25
+
26
+ const headers = { authorization: "Bearer " + register.data.token };
27
+ const me = await api.get("/me", { headers });
28
+ expect(me).toSucceed();
29
+ expect(me.data.email).toEqual(EMAIL);
30
+
31
+ const logout = await api.post("/auth/logout", {}, { headers });
32
+ expect(logout).toSucceed();
33
+ expect(await store.keys()).toEqual(["user:abc@test.com"]);
34
+
35
+ const login = await api.post("/auth/login/email", CREDENTIALS);
36
+ expect(login).toSucceed();
37
+ expect(await store.keys()).toEqual([
38
+ "user:abc@test.com",
39
+ "auth:" + login.data.token,
40
+ ]);
41
+ });
42
+ });
@@ -0,0 +1,52 @@
1
+ import { ServerError } from "../index.js";
2
+
3
+ const validateToken = (authorization) => {
4
+ const [type, id] = authorization.trim().split(" ");
5
+ if (type.toLowerCase() !== "bearer") {
6
+ throw ServerError.AUTH_INVALID_TYPE({ type });
7
+ }
8
+ if (id.length !== 24) {
9
+ throw ServerError.AUTH_INVALID_TOKEN();
10
+ }
11
+ return id;
12
+ };
13
+
14
+ const validateCookie = (authorization) => {
15
+ if (authorization.length !== 24) {
16
+ throw ServerError.AUTH_INVALID_COOKIE();
17
+ }
18
+ return authorization;
19
+ };
20
+
21
+ const findSessionId = (ctx) => {
22
+ const type = ctx.options.auth.type;
23
+
24
+ if (type === "token") {
25
+ // If the user is not authenticated, there's no auth to retrieve
26
+ if (!ctx.headers.authorization) return;
27
+
28
+ // Check the authentication header
29
+ return validateToken(ctx.headers.authorization);
30
+ }
31
+
32
+ if (type === "cookie") {
33
+ // If the user is not authenticated, there's no auth to retrieve
34
+ if (!ctx.cookies.authorization) return;
35
+
36
+ return validateCookie(ctx.cookies.authorization);
37
+ }
38
+
39
+ throw new Error("Invalid auth type " + type);
40
+ };
41
+
42
+ export default async function findAuth(ctx) {
43
+ if (!ctx.options.auth) return; // NO AUTH AT ALL; nothing to do here
44
+ const options = ctx.options.auth;
45
+
46
+ const sessionId = findSessionId(ctx);
47
+ if (!sessionId) return; // NO SESSION FOUND; no auth
48
+
49
+ const auth = await options.session.get(sessionId);
50
+ if (!auth) throw ServerError.AUTH_NO_SESSION();
51
+ return auth;
52
+ }
@@ -0,0 +1,24 @@
1
+ import auth from "./auth.js";
2
+ import logout from "./logout.js";
3
+ import providers from "./providers/index.js";
4
+ import session from "./session.js";
5
+ import user from "./user.js";
6
+
7
+ const load = async (ctx) => {
8
+ ctx.session = await session(ctx);
9
+ ctx.auth = await auth(ctx);
10
+ ctx.user = await user(ctx);
11
+ };
12
+
13
+ const middle = async (ctx) => {
14
+ if (ctx.options.auth) {
15
+ ctx.app.post("/auth/logout", logout);
16
+
17
+ if (ctx.options.auth.provider.includes("email")) {
18
+ ctx.app.post("/auth/register/email", providers.email.register);
19
+ ctx.app.post("/auth/login/email", providers.email.login);
20
+ }
21
+ }
22
+ };
23
+
24
+ export default { load, middle };
@@ -0,0 +1,123 @@
1
+ import "../test/toSucceed.js";
2
+
3
+ import kv from "polystore";
4
+
5
+ import server from "../index.js";
6
+
7
+ const ID = "REqA2l022l8Q0tuIRtqLOPUy";
8
+
9
+ describe("auth", () => {
10
+ it("requires a provider", () => {
11
+ const store = kv(new Map());
12
+ const api = server({ store, auth: "token:email" })
13
+ .get("/", (ctx) => ctx.auth)
14
+ .test();
15
+ });
16
+
17
+ it("provider must belong", async () => {
18
+ const store = kv(new Map());
19
+ const api = server({ store, auth: "token:email" })
20
+ .get("/", (ctx) => ctx.auth)
21
+ .test();
22
+
23
+ store.set(`auth:${ID}`, {
24
+ id: ID,
25
+ type: "token",
26
+ provider: "wrong",
27
+ user: "QypOn5SQApyOPdUpIZsO9u2O",
28
+ email: "abc@test.com",
29
+ time: "2024-07-01T03:21:40Z",
30
+ });
31
+ const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
32
+ const req = await api.get("/", { headers: { authorization } });
33
+ expect(req).not.toSucceed(
34
+ 'Invalid provider "wrong", valid ones are: "email"'
35
+ );
36
+ });
37
+ });
38
+
39
+ describe("token", () => {
40
+ const store = kv(new Map());
41
+ const api = server({ store, auth: "token:email" })
42
+ .get("/", (ctx) => ctx.auth)
43
+ .test();
44
+
45
+ afterEach(async () => {
46
+ await store.del(`auth:${ID}`);
47
+ });
48
+
49
+ it("should be Bearer", async () => {
50
+ const authorization = "Basic REqA2l022l8Q0tuIRtqLOPUy";
51
+ const req = await api.get("/", { headers: { authorization } });
52
+ expect(req).not.toSucceed("Invalid Authorization type, 'Basic'");
53
+ });
54
+
55
+ it("should have the proper token", async () => {
56
+ const authorization = "Bearer hola";
57
+ const req = await api.get("/", { headers: { authorization } });
58
+ expect(req).not.toSucceed("Invalid Authorization token");
59
+ });
60
+
61
+ it("cannot get the session", async () => {
62
+ const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
63
+ const req = await api.get("/", { headers: { authorization } });
64
+ expect(req).not.toSucceed("Invalid session");
65
+ });
66
+
67
+ it("cannot get the user", async () => {
68
+ store.set(`auth:${ID}`, {
69
+ id: ID,
70
+ type: "token",
71
+ provider: "email",
72
+ user: "QypOn5SQApyOPdUpIZsO9u2O",
73
+ email: "abc@test.com",
74
+ time: "2024-07-01T03:21:40Z",
75
+ });
76
+ const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
77
+ const req = await api.get("/", { headers: { authorization } });
78
+ expect(req).not.toSucceed("Credentials do not correspond to a user");
79
+ });
80
+
81
+ it("cannot get the user", async () => {
82
+ store.set("auth:REqA2l022l8Q0tuIRtqLOPUy", {
83
+ id: "REqA2l022l8Q0tuIRtqLOPUy",
84
+ type: "token",
85
+ provider: "email",
86
+ user: "QypOn5SQApyOPdUpIZsO9u2O",
87
+ email: "abc@test.com",
88
+ time: "2024-07-01T03:21:40Z",
89
+ });
90
+ store.set("user:abc@test.com", {
91
+ id: "QypOn5SQApyOPdUpIZsO9u2O",
92
+ email: "abc@test.com",
93
+ });
94
+ const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
95
+ const req = await api.get("/", { headers: { authorization } });
96
+ expect(req).toSucceed();
97
+ });
98
+ });
99
+
100
+ describe("cookie", () => {
101
+ const store = kv(new Map());
102
+ const api = server({ store, auth: "cookie:email" })
103
+ .get("/", (ctx) => ctx.auth)
104
+ .test();
105
+
106
+ it("should have the proper token in email", async () => {
107
+ const cookie = "authorization=hello";
108
+ const req = await api.get("/", { headers: { cookie } });
109
+ expect(req).not.toSucceed("Invalid Authorization cookie");
110
+ });
111
+
112
+ it("can get the proper session", async () => {
113
+ const cookie = "authorization=REqA2l022l8Q0tuIRtqLOPUy";
114
+ const req = await api.get("/", { headers: { cookie } });
115
+ expect(req).not.toSucceed("Invalid session");
116
+ });
117
+
118
+ it("can get the proper session", async () => {
119
+ const cookie = "authorization=REqA2l022l8Q0tuIRtqLOPUy";
120
+ const req = await api.get("/", { headers: { cookie } });
121
+ expect(req).not.toSucceed();
122
+ });
123
+ });
@@ -0,0 +1,18 @@
1
+ import { cookies } from "../reply.js";
2
+
3
+ export default async function logout(ctx) {
4
+ const { id, type } = ctx.auth;
5
+ await ctx.options.auth.session.del(id);
6
+
7
+ if (type === "token") {
8
+ return { token: null };
9
+ } else if (type === "cookie") {
10
+ return cookies({ authorization: null }).send({});
11
+ } else if (type === "jwt") {
12
+ throw new Error("JWT auth not supported yet");
13
+ } else if (type === "key") {
14
+ throw new Error("Key auth not supported yet");
15
+ } else {
16
+ throw new Error("Unknown auth type");
17
+ }
18
+ }
@@ -0,0 +1,73 @@
1
+ import argon2 from "argon2";
2
+
3
+ import { createId } from "../../helpers/index.js";
4
+ import { ServerError, status } from "../../index.js";
5
+
6
+ const createSession = async (user, ctx) => {
7
+ const { type, session, cleanUser } = ctx.options.auth;
8
+ user = cleanUser(user);
9
+ const token = createId();
10
+ const provider = "email";
11
+ const time = new Date().toISOString().replace(/\.[0-9]*/, "");
12
+ ctx.auth = {
13
+ id: token,
14
+ type,
15
+ provider,
16
+ user: user.id,
17
+ email: user.email,
18
+ time,
19
+ };
20
+ await session.set(token, ctx.auth, { expires: "1w" });
21
+
22
+ if (type === "token") {
23
+ return status(201).json({ ...user, token });
24
+ } else if (type === "cookie") {
25
+ return status(201).cookies({ authorization: token }).send(user);
26
+ } else if (type === "jwt") {
27
+ throw new Error("JWT auth not supported yet");
28
+ } else if (type === "key") {
29
+ throw new Error("Key auth not supported yet");
30
+ } else {
31
+ throw new Error("Unknown auth type");
32
+ }
33
+ };
34
+
35
+ async function login(ctx) {
36
+ const { email, password } = ctx.body;
37
+ if (!email) throw ServerError.LOGIN_NO_EMAIL();
38
+ if (!/@/.test(email)) throw ServerError.LOGIN_INVALID_EMAIL();
39
+ if (!password) throw ServerError.LOGIN_NO_PASSWORD();
40
+ if (password.length < 8) throw ServerError.LOGIN_INVALID_PASSWORD();
41
+
42
+ const store = ctx.options.auth.store;
43
+ if (!(await store.has(email))) throw ServerError.LOGIN_WRONG_EMAIL();
44
+
45
+ const user = await store.get(email);
46
+ const isValid = await argon2.verify(user.password, password);
47
+ if (!isValid) throw ServerError.LOGIN_WRONG_PASSWORD();
48
+
49
+ return createSession(user, ctx);
50
+ }
51
+
52
+ async function register(ctx) {
53
+ const { email, password, ...data } = ctx.body;
54
+ if (!email) throw ServerError.REGISTER_NO_EMAIL();
55
+ if (!/@/.test(email)) throw ServerError.REGISTER_INVALID_EMAIL();
56
+ if (!password) throw ServerError.REGISTER_NO_PASSWORD();
57
+ if (password.length < 8) throw ServerError.REGISTER_INVALID_PASSWORD();
58
+
59
+ const store = ctx.options.auth.store;
60
+ if (await store.has(email)) throw ServerError.REGISTER_EMAIL_EXISTS();
61
+
62
+ const user = {
63
+ id: createId(),
64
+ email,
65
+ password: await argon2.hash(password),
66
+ ...data,
67
+ };
68
+ await store.set(email, user);
69
+
70
+ return createSession(user, ctx);
71
+ }
72
+
73
+ export default { login, register };
@@ -0,0 +1,3 @@
1
+ import { default as email } from "./email.js";
2
+
3
+ export default { email };
@@ -0,0 +1,18 @@
1
+ import NoSession from "./NoSession.js";
2
+
3
+ export default async function session(ctx) {
4
+ const store = ctx.options.session?.store;
5
+
6
+ // If there's no store at all, we don't have session available;
7
+ // but that's okay, since it's only a problem if you try to use it
8
+ if (!store) return NoSession;
9
+
10
+ // There's a session cookie; use it as the key to get the data
11
+ // from the store
12
+ if (ctx.cookies.session) {
13
+ const session = await store.get(ctx.cookies.session);
14
+ if (session) return session;
15
+ }
16
+
17
+ return {};
18
+ }
@@ -0,0 +1,21 @@
1
+ import ServerError from "../ServerError";
2
+
3
+ const findUser = async (auth, options) => {
4
+ if (!auth.provider) throw ServerError.AUTH_NO_PROVIDER();
5
+ if (!options.provider.includes(auth.provider)) {
6
+ const valid = JSON.stringify(options.provider);
7
+ throw ServerError.AUTH_INVALID_PROVIDER({ provider: auth.provider, valid });
8
+ }
9
+
10
+ if (auth.provider === "email") {
11
+ return await options.store.get(auth.email);
12
+ }
13
+ };
14
+
15
+ export default async function user(ctx) {
16
+ if (!ctx.auth) return;
17
+
18
+ const user = await findUser(ctx.auth, ctx.options.auth);
19
+ if (!user) throw ServerError.AUTH_NO_USER();
20
+ return ctx.options.auth.cleanUser(user);
21
+ }
@@ -1,9 +1,14 @@
1
+ import auth from "../auth/index.js";
1
2
  import { define } from "../helpers/index.js";
2
- import findAuth from "./findAuth.js";
3
- import findSession from "./findSession.js";
4
3
  import parseBody from "./parseBody.js";
5
4
  import parseCookies from "./parseCookies.js";
6
5
 
6
+ // https://stackoverflow.com/a/54029307/938236
7
+ const chunkArray = (arr, size) =>
8
+ arr.length > size
9
+ ? [arr.slice(0, size), ...chunkArray(arr.slice(size), size)]
10
+ : [arr];
11
+
7
12
  export default async (request, options = {}, app) => {
8
13
  const ctx = {};
9
14
  ctx.options = options;
@@ -11,10 +16,9 @@ export default async (request, options = {}, app) => {
11
16
  ctx.res = { status: null, headers: {}, cookies: {} };
12
17
  ctx.method = request.method.toLowerCase();
13
18
 
14
- ctx.headers = request.headers;
19
+ ctx.headers = parseHeaders(new Headers(chunkArray(request.rawHeaders, 2)));
15
20
  ctx.cookies = parseCookies(ctx.headers.cookie);
16
- ctx.session = await findSession(ctx);
17
- await findAuth(ctx);
21
+ await auth.load(ctx);
18
22
 
19
23
  const https = request.connection.encrypted ? "https" : "http";
20
24
  const host = ctx.headers.host || "localhost" + options.port;
@@ -1,6 +1,5 @@
1
- import { define } from "../helpers/index.js";
2
- import findAuth from "./findAuth.js";
3
- import findSession from "./findSession.js";
1
+ import auth from "../auth/index.js";
2
+ import { define, parseHeaders } from "../helpers/index.js";
4
3
  import parseBody from "./parseBody.js";
5
4
  import parseCookies from "./parseCookies.js";
6
5
 
@@ -11,10 +10,9 @@ export default async (request, options = {}, app) => {
11
10
  ctx.res = { status: null, headers: {}, cookies: {} };
12
11
  ctx.method = request.method.toLowerCase();
13
12
 
14
- ctx.headers = Object.fromEntries(request.headers.entries());
13
+ ctx.headers = parseHeaders(request.headers);
15
14
  ctx.cookies = parseCookies(ctx.headers.cookie);
16
- ctx.session = await findSession(ctx);
17
- await findAuth(ctx);
15
+ await auth.load(ctx);
18
16
 
19
17
  ctx.url = new URL(request.url.replace(/\/$/, ""));
20
18
  define(ctx.url, "query", (url) =>
@@ -6,6 +6,14 @@ ServerError.extend({
6
6
  NO_STORE_READ: `You need a 'store' to read 'ctx.session.{key}'`,
7
7
  AUTH_INVALID_TYPE: `Invalid Authorization type, '{type}'`,
8
8
  AUTH_INVALID_TOKEN: `Invalid Authorization token`,
9
+ AUTH_INVALID_COOKIE: `Invalid Authorization cookie`,
10
+ AUTH_NO_PROVIDER: `No provider passed to the option "auth.provider"`,
11
+ AUTH_INVALID_PROVIDER: `Invalid provider "{provider}", valid ones are: {valid}`,
12
+ AUTH_NO_SESSION: { status: 401, message: "Invalid session" },
13
+ AUTH_NO_USER: {
14
+ status: 401,
15
+ message: "Credentials do not correspond to a user",
16
+ },
9
17
 
10
18
  LOGIN_NO_EMAIL: "The email is required to log in",
11
19
  LOGIN_INVALID_EMAIL: "The email you wrote is not correct",
@@ -13,6 +21,12 @@ ServerError.extend({
13
21
  LOGIN_INVALID_PASSWORD: "The email you wrote is not correct",
14
22
  LOGIN_WRONG_ACCOUNT: `That email does not correspond to any account`,
15
23
  LOGIN_WRONG_PASSWORD: `That is not the valid password`,
24
+
25
+ REGISTER_NO_EMAIL: `Email needed`,
26
+ REGISTER_INVALID_EMAIL: "The email you wrote is not correct",
27
+ REGISTER_NO_PASSWORD: `Password needed`,
28
+ REGISTER_INVALID_PASSWORD: "The email you wrote is not correct",
29
+ REGISTER_EMAIL_EXISTS: `Email is already registered`,
16
30
  });
17
31
 
18
32
  export default ServerError;
@@ -2,10 +2,15 @@
2
2
  export default function createCookies(cookies) {
3
3
  if (!cookies || !Object.keys(cookies).length) return [];
4
4
  return Object.entries(cookies).map(([key, val]) => {
5
+ if (!val) {
6
+ val = { value: "", expires: new Date(0).toUTCString() };
7
+ }
5
8
  if (typeof val === "string") {
6
- val = { value: val, path };
9
+ val = { value: val };
7
10
  }
8
- const { value, path } = val;
9
- return `${key}=${value}${path ? ";Path=" + path : ""}`;
11
+ const { value, path, expires } = val;
12
+ const pathPart = path ? ";Path=" + path : "";
13
+ const expiresPart = expires ? ";Expires=" + expires : "";
14
+ return `${key}=${value || ""}${pathPart}${expiresPart}`;
10
15
  });
11
16
  }
@@ -4,4 +4,5 @@ export { default as define } from "./define.js";
4
4
  export { default as getMachine } from "./getMachine.js";
5
5
  export { default as handleRequest } from "./handleRequest.js";
6
6
  export { default as iterate } from "./iterate.js";
7
+ export { default as parseHeaders } from "./parseHeaders.js";
7
8
  export { default as types } from "./types.js";
@@ -0,0 +1,15 @@
1
+ export default (raw) => {
2
+ const headers = {};
3
+ for (let [key, value] of raw.entries()) {
4
+ key = key.toLowerCase();
5
+ if (headers[key]) {
6
+ if (!Array.isArray(headers[key])) {
7
+ headers[key] = [headers[key]];
8
+ }
9
+ headers[key].push(value);
10
+ } else {
11
+ headers[key] = value;
12
+ }
13
+ }
14
+ return headers;
15
+ };
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getMachine,
11
11
  handleRequest,
12
12
  iterate,
13
+ parseHeaders,
13
14
  } from "./helpers/index.js";
14
15
  import middle from "./middle/index.js";
15
16
 
@@ -59,13 +60,34 @@ const validateOptions = (options, env = {}) => {
59
60
  if (options.store && options.cookies) {
60
61
  options.session = { store: options.store.prefix("session:") };
61
62
  }
62
- options.auth = options.auth || {};
63
+
64
+ // AUTH
65
+ options.auth = options.auth || env.AUTH || null;
63
66
  if (options.auth) {
64
67
  if (typeof options.auth !== "object") {
65
- options.auth = { type: options.auth };
68
+ const [type, provider] = options.auth.split(":");
69
+ options.auth = { type, provider };
70
+ }
71
+ if (typeof options.auth.provider === "string") {
72
+ options.auth.provider === options.auth.provider.split("|");
73
+ }
74
+ if (!options.auth.type) {
75
+ throw new Error("Auth options needs a type");
76
+ }
77
+ if (!options.auth.provider) {
78
+ throw new Error("Auth options needs a provider");
79
+ }
80
+ if (!options.auth.session && options.store) {
81
+ options.auth.session = options.store.prefix("auth:");
66
82
  }
67
83
  if (!options.auth.store && options.store) {
68
- options.auth.store = options.store.prefix("auth:");
84
+ options.auth.store = options.store.prefix("user:");
85
+ }
86
+ if (!options.auth.cleanUser) {
87
+ options.auth.cleanUser = (fullUser) => {
88
+ const { password, ...user } = fullUser;
89
+ return user;
90
+ };
69
91
  }
70
92
  }
71
93
 
@@ -212,17 +234,7 @@ server.prototype.test = function () {
212
234
  new Request("http://localhost:3000" + path, options)
213
235
  );
214
236
 
215
- const headers = {};
216
- for (const [key, value] of res.headers.entries()) {
217
- if (headers[key]) {
218
- if (!Array.isArray(headers[key])) {
219
- headers[key] = [headers[key]];
220
- }
221
- headers[key].push(value);
222
- } else {
223
- headers[key] = value;
224
- }
225
- }
237
+ const headers = parseHeaders(res.headers);
226
238
  let data;
227
239
  if (headers["set-cookie"]) {
228
240
  // TODO: this should really be a smart merge of the 2
@@ -236,6 +248,7 @@ server.prototype.test = function () {
236
248
  return { status: res.status, headers, data };
237
249
  };
238
250
  return {
251
+ app: this,
239
252
  get: (path, options) => fetch(path, { method: "get", ...options }),
240
253
  head: (path, options) => fetch(path, { method: "head", ...options }),
241
254
  post: (path, body, options) =>
package/src/index.test.js CHANGED
@@ -1,3 +1,5 @@
1
+ import "./test/toSucceed.js";
2
+
1
3
  import server, { status } from "./index.js";
2
4
 
3
5
  describe("return different types", () => {
@@ -20,13 +22,14 @@ describe("return different types", () => {
20
22
  });
21
23
 
22
24
  it("can get the object", async () => {
23
- const { data } = await api.get("/object");
24
- expect(data).toEqual({ hello: "world" });
25
+ const req = await api.get("/object");
26
+ expect(req).toSucceed({ hello: "world" });
25
27
  });
26
28
 
27
29
  it("can get the status", async () => {
28
- const { status } = await api.get("/status");
29
- expect(status).toBe(201);
30
+ const req = await api.get("/status");
31
+ expect(req).toSucceed();
32
+ expect(req.status).toBe(201);
30
33
  });
31
34
  });
32
35
 
@@ -0,0 +1,12 @@
1
+ import { type } from "../reply.js";
2
+
3
+ export default async function assets(ctx) {
4
+ try {
5
+ // TODO: streaming
6
+ const asset = await ctx.options.public.read(ctx.url.pathname);
7
+ if (!asset) return;
8
+ return type(ctx.url.pathname.split(".").pop()).send(asset);
9
+ } catch (error) {
10
+ // NO-OP; if there's no file, keep going the normal flow
11
+ }
12
+ }
@@ -1,71 +1,12 @@
1
- import argon2 from "argon2";
1
+ import auth from "../auth/index.js";
2
+ import assets from "./assets.js";
2
3
 
3
- import { createId } from "../helpers/index.js";
4
- import { status, type } from "../reply.js";
5
- import ServerError from "../ServerError.js";
6
-
7
- export default function middle(ctx) {
4
+ export default async function middle(ctx) {
5
+ // Serve assets
8
6
  if (ctx.options.public) {
9
- ctx.app.handlers.get.unshift([
10
- "*",
11
- "*",
12
- async function publicFolder(ctx) {
13
- try {
14
- const asset = await ctx.options.public.read(ctx.url.pathname);
15
- if (asset) {
16
- return type(ctx.url.pathname.split(".").pop()).send(asset);
17
- }
18
- } catch (error) {}
19
- },
20
- ]);
7
+ // We need these before other endpoints
8
+ ctx.app.handlers.get.unshift(["*", "*", assets]);
21
9
  }
22
10
 
23
- if (!ctx.auth) return;
24
- if (ctx.auth?.providers?.includes("email")) {
25
- ctx.app.post("/auth/register/email", async (ctx) => {
26
- const { email, password, ...data } = ctx.body;
27
- if (!email || !/@/.test(email)) throw new Error("Email needed");
28
- if (!password || password.length < 8) throw new Error("Password needed");
29
- if (await ctx.auth.store.has(email)) {
30
- throw new Error("Email is already registered");
31
- }
32
- const id = createId();
33
- const time = new Date().toISOString();
34
- const pass = await argon2.hash(password);
35
- await ctx.auth.store.set(email, { id, email, password: pass, ...data });
36
-
37
- // TYPE GOES HERE
38
- const token = await ctx.options.session.store.add({ id, email, time });
39
- return status(201).json({ id, token, email, ...data });
40
- });
41
-
42
- ctx.app.post("/auth/logout", async (ctx) => {
43
- if (ctx.auth.id) {
44
- await ctx.options.session.store.del(ctx.auth.id);
45
- }
46
- return status(200).send();
47
- });
48
-
49
- ctx.app.post("/auth/login/email", async (ctx) => {
50
- const { email, password } = ctx.body;
51
- if (!email) throw ServerError.LOGIN_NO_EMAIL();
52
- if (!/@/.test(email)) throw ServerError.LOGIN_INVALID_EMAIL();
53
- if (!password) throw ServerError.LOGIN_NO_PASSWORD();
54
- if (password.length < 8) throw ServerError.LOGIN_INVALID_PASSWORD();
55
-
56
- const time = new Date().toISOString();
57
- const user = await ctx.auth.store.get(email);
58
- if (!user) throw ServerError.LOGIN_WRONG_EMAIL();
59
- const isValid = await argon2.verify(user.password, password);
60
- if (!isValid) throw ServerError.LOGIN_WRONG_PASSWORD();
61
-
62
- // TYPE GOES HERE
63
- const token = await ctx.options.session.store.add({
64
- id: user.id,
65
- email,
66
- time,
67
- });
68
- return { id: user.id, token, email };
69
- });
70
- }
11
+ await auth.middle(ctx);
71
12
  }
@@ -0,0 +1,64 @@
1
+ import { expect } from "@jest/globals";
2
+
3
+ const reset = "\x1b[0m";
4
+
5
+ const spaceOrEnter = (msg) => {
6
+ if (typeof msg === "string") {
7
+ return " ";
8
+ }
9
+ return "\n";
10
+ };
11
+
12
+ export default function toSucceed(request, message) {
13
+ const pass = request.status >= 200 && request.status < 300;
14
+
15
+ if (message && JSON.stringify(request.data) !== JSON.stringify(message)) {
16
+ if (pass) {
17
+ return {
18
+ message: () =>
19
+ `${reset}Expected body:${spaceOrEnter(
20
+ message
21
+ )}${this.utils.printExpected(message)}\nReceived body:${spaceOrEnter(
22
+ request.data
23
+ )}${this.utils.printReceived(request.data)}`,
24
+ pass,
25
+ };
26
+ }
27
+
28
+ return {
29
+ message: () =>
30
+ `${reset}Expected error:${spaceOrEnter(
31
+ message
32
+ )}${this.utils.printExpected(message)}\nReceived error:${spaceOrEnter(
33
+ request.data
34
+ )}${this.utils.printReceived(request.data)}`,
35
+ pass: !pass,
36
+ };
37
+ }
38
+
39
+ if (pass) {
40
+ return {
41
+ message: () =>
42
+ `${reset}Expected ${this.utils.printExpected(
43
+ request.status
44
+ )} to be an error code, received body:\n${this.utils.printExpected(
45
+ request.data
46
+ )}`,
47
+ pass: true,
48
+ };
49
+ }
50
+
51
+ return {
52
+ message: () =>
53
+ `${reset}Expected ${this.utils.printReceived(
54
+ request.status
55
+ )} to succeed, received body:\n${this.utils.printReceived(
56
+ request.data,
57
+ null,
58
+ 2
59
+ )}`,
60
+ pass: false,
61
+ };
62
+ }
63
+
64
+ expect.extend({ toSucceed });
package/src/auth.test.js DELETED
@@ -1,62 +0,0 @@
1
- import kv from "polystore";
2
-
3
- import server from "./index.js";
4
-
5
- describe("auth", () => {
6
- const store = kv(new Map());
7
- const api = server({ store })
8
- .get("/", (ctx) => ctx.headers.authorization)
9
- .test();
10
-
11
- it("should be Bearer", async () => {
12
- const authorization = "Basic REqA2l022l8Q0tuIRtqLOPUy";
13
- const { data } = await api.get("/", { headers: { authorization } });
14
- expect(data).toBe("Invalid Authorization type, 'Basic'");
15
- });
16
-
17
- it("should have the proper token", async () => {
18
- const authorization = "Bearer hola";
19
- const { data } = await api.get("/", { headers: { authorization } });
20
- expect(data).toBe("Invalid Authorization token");
21
- });
22
-
23
- it("can get the nested get", async () => {
24
- const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
25
- const { data } = await api.get("/", { headers: { authorization } });
26
- expect(data).toBe("Bearer REqA2l022l8Q0tuIRtqLOPUy");
27
- });
28
- });
29
-
30
- describe("user creation flow", () => {
31
- // These are obviously mock data
32
- const EMAIL = "abc@test.com";
33
- const PASS = "11111111";
34
- const CREDENTIALS = { email: EMAIL, password: PASS };
35
-
36
- const store = kv(new Map());
37
- const auth = { type: "token", providers: "email" };
38
- const api = server({ auth, store }).test();
39
-
40
- it("can create a new user", async () => {
41
- const register = await api.post("/auth/register/email", CREDENTIALS);
42
- expect(register.status).toBe(201);
43
- expect(await store.keys()).toEqual([
44
- "auth:abc@test.com",
45
- "session:" + register.data.token,
46
- ]);
47
-
48
- const authorization = "Bearer " + register.data.token;
49
- const headers = { authorization };
50
- const logout = await api.post("/auth/logout", {}, { headers });
51
-
52
- expect(logout.status).toBe(200);
53
- expect(await store.keys()).toEqual(["auth:abc@test.com"]);
54
-
55
- const login = await api.post("/auth/login/email", CREDENTIALS);
56
- expect(login.status).toBe(200);
57
- expect(await store.keys()).toEqual([
58
- "auth:abc@test.com",
59
- "session:" + login.data.token,
60
- ]);
61
- });
62
- });
@@ -1,32 +0,0 @@
1
- import { ServerError } from "../index.js";
2
-
3
- export default async function findAuth(ctx) {
4
- // NO AUTH AT ALL
5
- // If there's no even auth option, nothing to do
6
- const store = ctx.options.auth?.store;
7
- if (!store) return;
8
-
9
- // AUTHENTICATION IS AVAILABLE
10
- ctx.auth = {
11
- type: ctx.options.auth.type,
12
- providers: ctx.options.auth.providers,
13
- store,
14
- };
15
-
16
- // If the user is not authenticated, there's no auth to retrieve
17
- if (!ctx.headers.authorization) return;
18
-
19
- // AUTHENTICATED REQUEST
20
- // Check the authentication header
21
- const [type, id] = ctx.headers.authorization.trim().split(" ");
22
- if (type.toLowerCase() !== "bearer") {
23
- throw ServerError.AUTH_INVALID_TYPE({ type });
24
- }
25
- if (id.length !== 24) {
26
- throw ServerError.AUTH_INVALID_TOKEN();
27
- }
28
-
29
- // Extend the basics
30
- ctx.auth.id = id;
31
- ctx.user = await store.get(id);
32
- }
@@ -1,34 +0,0 @@
1
- import { ServerError } from "../index.js";
2
-
3
- class NoSession {}
4
-
5
- export default async function findSession(ctx) {
6
- const store = ctx.options.session?.store;
7
-
8
- // If there's no store at all, we don't have session available;
9
- // but that's okay, since it's only a problem if you try to use it
10
- if (!store) {
11
- return new Proxy(new NoSession(), {
12
- get(target, key) {
13
- if (target[key]) return target[key];
14
- if (key === "then") return target[key];
15
- throw ServerError.NO_STORE_READ({ key });
16
- },
17
- set(target, key, value) {
18
- if (target[key] || key === "then") {
19
- target[key] = value;
20
- } else {
21
- throw ServerError.NO_STORE_WRITE({ key });
22
- }
23
- },
24
- });
25
- }
26
-
27
- // There's a session cookie; use it as the key to get the data
28
- // from the store
29
- if (ctx.cookies.session) {
30
- return (await store.get(ctx.cookies.session)) || {};
31
- }
32
-
33
- return {};
34
- }