diff --git a/.gitignore b/.gitignore index a33b78f0..899417bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ .idea/ +docs/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..f5251fe5 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# ferns-api + +This library attempts to make creating REST APIs much easier with Express and Mongoose. +Most REST APIs wind up being a lot of boilerplate, so this tries to cut that down without turning +into a full blown framework of its own. This library is inspired by the +[Django-REST-Framework](https://www.django-rest-framework.org). + +### Coming soon: + +A frontend library to consume these APIs with Redux Toolkit Query. + +## Getting started + +To install: + + yarn install ferns-api + +## Usage + +Assuming we have a model: + + const foodSchema = new Schema({ + name: String, + hidden: {type: Boolean, default: false}, + ownerId: {type: "ObjectId", ref: "User"}, + }); + export const FoodModel = model("Food", foodSchema); + +We can expose this model as an API like this: + + import express from "express"; + import {fernsRouter, Permissions} from "ferns-api"; + + const app = express(); + app.use( + "/foods", + fernsRouter(UserModel, { + permissions: { + list: [Permissions.IsAny], + create: [Permissions.IsAuthenticated], + read: [Permissions.IsAny], + update: [Permissions.IsOwner], + delete: [Permissions.IsAdmin], + }, + }) + ); + +Now we can perform operations on the Food model in a standard REST way. We've also added some permissioning. + + # Gets a list of foods. Anyone can do this without being authenticated. + GET /foods + { + data: [{_id: "62c86d787c7e2db0bf286acd", name: "Carrots", hidden: false, ownerId: "62c44d9f003d9f8ee8cc9256"}], + more: false, + page: 1, + limit: 100 + } + + # Get a specific food. Anyone can do this. + GET /foods/62c86d787c7e2db0bf286acd + {_id: "62c86d787c7e2db0bf286acd", name: "Carrots", hidden: false, ownerId: "62c44d9f003d9f8ee8cc9256"} + + # Creates a new food. Only authenticated users are allowed to do this. + POST /foods {name: "Broccoli", ownerId: "62c44d9f003d9f8ee8cc9256"} + {_id: "62c86d787c7e2db0bf286000", name: "Broccoli", hidden: false, ownerId: "62c44d9f003d9f8ee8cc9256"} + + # Updates an existing food. Only the owner of the food can do this, otherwise an error code is returned. + PATCH /foods/62c86d787c7e2db0bf286acd {name: "Peas And Carrots"} + {_id: "62c86d787c7e2db0bf286acd", name: "Peas And Carrots", hidden: false, ownerId: "62c44d9f003d9f8ee8cc9256"} + + # Deletes an existing food. Only admins are allowed to do this (users with `user.admin` set to true). + DELETE /foods/62c86d787c7e2db0bf286acd + +You can create your own permissions functions. Check permissions.ts for some examples of how to write them. + +## Example + +To test out how the API works, you can look at and run [example.ts]. + +## Dev + +To continuously compile the package: + + yarn dev + +To run tests, linting, and fixing up lint issues: + + yarn lint + yarn lintfix + yarn test + +To see how your changes will affect the docs: + + yarn docs + cd docs/ + npx http-server + +A lot of dev may require using yarn link. You'll want to keep the `yarn dev` window running to continuously compile: + + yarn link + cd $your-api-repo + yarn link ferns-api + diff --git a/README.me b/README.me deleted file mode 100644 index 6be1a2e3..00000000 --- a/README.me +++ /dev/null @@ -1,2 +0,0 @@ -@ferns/api ---- diff --git a/package.json b/package.json index 80471aa8..2093fa34 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev": "tsc -w", "lint": "eslint \"src/**/*.ts*\"", "lintfix": "eslint --fix \"src/**/*.ts*\"", - "test": "jest -i" + "test": "jest -i", + "docs": "typedoc --out docs src/index.ts" }, "repository": { "type": "git", @@ -85,6 +86,7 @@ "sinon": "^14.0.0", "supertest": "^6.1.6", "ts-jest": "^28.0.5", + "typedoc": "~0.23.0", "typescript": "4.1.5" }, "prettier": { diff --git a/src/api.test.ts b/src/api.test.ts index 096f42db..1f94ceef 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,175 +1,28 @@ import chai from "chai"; -import express, {Express} from "express"; +import express from "express"; import sortBy from "lodash/sortBy"; -import mongoose, {model, ObjectId, Schema} from "mongoose"; +import mongoose from "mongoose"; import qs from "qs"; import supertest from "supertest"; +import {fernsRouter} from "./api"; +import {setupAuth} from "./auth"; +import {Permissions} from "./permissions"; import { - AdminOwnerTransformer, - createdUpdatedPlugin, - gooseRestRouter, - Permissions, - setupAuth, - tokenPlugin, -} from "./api"; -import {passportLocalMongoose} from "./passport"; + authAsUser, + Food, + FoodModel, + getBaseServer, + setupDb, + StaffUser, + StaffUserModel, + SuperUser, + SuperUserModel, + UserModel, +} from "./tests"; const assert = chai.assert; -mongoose.connect("mongodb://localhost:27017/ferns"); - -interface User { - admin: boolean; - username: string; - email: string; - age?: number; -} - -interface SuperUser extends User { - superTitle: string; -} - -interface StaffUser extends User { - department: string; -} - -interface FoodCategory { - _id?: string; - name: string; - show: boolean; -} - -interface Food { - _id: string; - name: string; - calories: number; - created: Date; - ownerId: mongoose.Types.ObjectId | User; - hidden?: boolean; - source: { - name: string; - }; - tags: string[]; - categories: FoodCategory[]; -} - -const userSchema = new Schema({ - username: String, - admin: {type: Boolean, default: false}, - age: Number, -}); - -userSchema.plugin(passportLocalMongoose, {usernameField: "email"}); -userSchema.plugin(tokenPlugin); -userSchema.plugin(createdUpdatedPlugin); -userSchema.methods.postCreate = async function (body: any) { - this.age = body.age; - return this.save(); -}; - -const UserModel = model("User", userSchema); - -const superUserSchema = new Schema({ - superTitle: {type: String, required: true}, -}); -const SuperUserModel = UserModel.discriminator("SuperUser", superUserSchema); - -const staffUserSchema = new Schema({ - department: {type: String, required: true}, -}); -const StaffUserModel = UserModel.discriminator("Staff", staffUserSchema); - -const foodCategorySchema = new Schema({ - name: String, - show: Boolean, -}); - -const foodSchema = new Schema({ - name: String, - calories: Number, - created: Date, - ownerId: {type: "ObjectId", ref: "User"}, - source: { - name: String, - }, - hidden: {type: Boolean, default: false}, - tags: [String], - categories: [foodCategorySchema], -}); - -const FoodModel = model("Food", foodSchema); - -interface RequiredField { - name: string; - about?: string; -} - -const requiredSchema = new Schema({ - name: {type: String, required: true}, - about: String, -}); -const RequiredModel = model("Required", requiredSchema); - -function getBaseServer(): Express { - const app = express(); - - app.all("/*", function (req, res, next) { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Headers", "*"); - // intercepts OPTIONS method - if (req.method === "OPTIONS") { - res.send(200); - } else { - next(); - } - }); - app.use(express.json()); - return app; -} - -afterAll(() => { - mongoose.connection.close(); -}); - -export async function authAsUser( - app: express.Application, - type: "admin" | "notAdmin" -): Promise { - const email = type === "admin" ? "admin@example.com" : "notAdmin@example.com"; - const password = type === "admin" ? "securePassword" : "password"; - - const agent = supertest.agent(app); - const res = await agent.post("/auth/login").send({email, password}).expect(200); - agent.set("authorization", `Bearer ${res.body.data.token}`); - return agent; -} - -async function setupDb() { - process.env.TOKEN_SECRET = "secret"; - process.env.TOKEN_EXPIRES_IN = "30m"; - process.env.TOKEN_ISSUER = "example.com"; - process.env.SESSION_SECRET = "session"; - - try { - await Promise.all([UserModel.deleteMany({}), FoodModel.deleteMany({})]); - const [notAdmin, admin] = await Promise.all([ - UserModel.create({email: "notAdmin@example.com"}), - UserModel.create({email: "admin@example.com", admin: true}), - ]); - await (notAdmin as any).setPassword("password"); - await notAdmin.save(); - - await (admin as any).setPassword("securePassword"); - await admin.save(); - - return [admin, notAdmin]; - } catch (e) { - console.error("Error setting up DB", e); - throw e; - } -} - describe("ferns-api", () => { let server: supertest.SuperTest; let app: express.Application; @@ -188,7 +41,7 @@ describe("ferns-api", () => { let deleteCalled = false; app.use( "/food", - gooseRestRouter(FoodModel, { + fernsRouter(FoodModel, { permissions: { list: [Permissions.IsAny], create: [Permissions.IsAny], @@ -256,7 +109,7 @@ describe("ferns-api", () => { app.use( "/food", - gooseRestRouter(FoodModel, { + fernsRouter(FoodModel, { permissions: { list: [Permissions.IsAny], create: [Permissions.IsAny], @@ -295,7 +148,7 @@ describe("ferns-api", () => { let deleteCalled = false; app.use( "/food", - gooseRestRouter(FoodModel, { + fernsRouter(FoodModel, { permissions: { list: [Permissions.IsAny], create: [Permissions.IsAny], @@ -355,384 +208,6 @@ describe("ferns-api", () => { }); }); - describe("permissions", function () { - beforeEach(async function () { - const [admin, notAdmin] = await setupDb(); - - await Promise.all([ - FoodModel.create({ - name: "Spinach", - calories: 1, - created: new Date(), - ownerId: notAdmin._id, - }), - FoodModel.create({ - name: "Apple", - calories: 100, - created: new Date().getTime() - 10, - ownerId: admin._id, - }), - ]); - app = getBaseServer(); - setupAuth(app, UserModel as any); - app.use( - "/food", - gooseRestRouter(FoodModel, { - permissions: { - list: [Permissions.IsAny], - create: [Permissions.IsAuthenticated], - read: [Permissions.IsAny], - update: [Permissions.IsOwner], - delete: [Permissions.IsAdmin], - }, - }) - ); - app.use( - "/required", - gooseRestRouter(RequiredModel, { - permissions: { - list: [Permissions.IsAny], - create: [Permissions.IsAuthenticated], - read: [Permissions.IsAny], - update: [Permissions.IsOwner], - delete: [Permissions.IsAdmin], - }, - }) - ); - server = supertest(app); - }); - - describe("anonymous food", function () { - it("list", async function () { - const res = await server.get("/food").expect(200); - assert.lengthOf(res.body.data, 2); - }); - - it("get", async function () { - const res = await server.get("/food").expect(200); - assert.lengthOf(res.body.data, 2); - const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200); - assert.equal(res.body.data[0]._id, res2.body.data._id); - }); - - it("post", async function () { - const res = await server.post("/food").send({ - name: "Broccoli", - calories: 15, - }); - assert.equal(res.status, 405); - }); - - it("patch", async function () { - const res = await server.get("/food"); - const res2 = await server.patch(`/food/${res.body.data[0]._id}`).send({ - name: "Broccoli", - }); - assert.equal(res2.status, 403); - }); - - it("delete", async function () { - const res = await server.get("/food"); - const res2 = await server.delete(`/food/${res.body.data[0]._id}`); - assert.equal(res2.status, 405); - }); - }); - - describe("non admin food", function () { - let agent: supertest.SuperAgentTest; - beforeEach(async function () { - agent = await authAsUser(app, "notAdmin"); - }); - - it("list", async function () { - const res = await agent.get("/food").expect(200); - assert.lengthOf(res.body.data, 2); - }); - - it("get", async function () { - const res = await agent.get("/food").expect(200); - assert.lengthOf(res.body.data, 2); - const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200); - assert.equal(res.body.data[0]._id, res2.body.data._id); - }); - - it("post", async function () { - await agent - .post("/food") - .send({ - name: "Broccoli", - calories: 15, - }) - .expect(201); - }); - - it("patch own item", async function () { - const res = await agent.get("/food"); - const spinach = res.body.data.find((food: Food) => food.name === "Spinach"); - const res2 = await agent - .patch(`/food/${spinach._id}`) - .send({ - name: "Broccoli", - }) - .expect(200); - assert.equal(res2.body.data.name, "Broccoli"); - }); - - it("patch other item", async function () { - const res = await agent.get("/food"); - const spinach = res.body.data.find((food: Food) => food.name === "Apple"); - await agent - .patch(`/food/${spinach._id}`) - .send({ - name: "Broccoli", - }) - .expect(403); - }); - - it("delete", async function () { - const res = await agent.get("/food"); - const res2 = await agent.delete(`/food/${res.body.data[0]._id}`); - assert.equal(res2.status, 405); - }); - }); - - describe("admin food", function () { - let agent: supertest.SuperAgentTest; - - beforeEach(async function () { - agent = await authAsUser(app, "admin"); - }); - - it("list", async function () { - const res = await agent.get("/food"); - assert.lengthOf(res.body.data, 2); - }); - - it("get", async function () { - const res = await agent.get("/food"); - assert.lengthOf(res.body.data, 2); - const res2 = await agent.get(`/food/${res.body.data[0]._id}`); - assert.equal(res.body.data[0]._id, res2.body.data._id); - }); - - it("post", async function () { - const res = await agent.post("/food").send({ - name: "Broccoli", - calories: 15, - }); - assert.equal(res.status, 201); - }); - - it("patch", async function () { - const res = await agent.get("/food"); - await agent - .patch(`/food/${res.body.data[0]._id}`) - .send({ - name: "Broccoli", - }) - .expect(200); - }); - - it("delete", async function () { - const res = await agent.get("/food"); - await agent.delete(`/food/${res.body.data[0]._id}`).expect(204); - }); - - it("handles validation errors", async function () { - await agent - .post("/required") - .send({ - about: "Whoops forgot required", - }) - .expect(400); - }); - }); - }); - - describe("query and transform", function () { - let notAdmin: any; - let admin: any; - - beforeEach(async function () { - [admin, notAdmin] = await setupDb(); - - await Promise.all([ - FoodModel.create({ - name: "Spinach", - calories: 1, - created: new Date(), - ownerId: notAdmin._id, - }), - FoodModel.create({ - name: "Apple", - calories: 100, - created: new Date().getTime() - 10, - ownerId: admin._id, - hidden: true, - }), - FoodModel.create({ - name: "Carrots", - calories: 100, - created: new Date().getTime() - 10, - ownerId: admin._id, - }), - ]); - app = getBaseServer(); - setupAuth(app, UserModel as any); - app.use( - "/food", - gooseRestRouter(FoodModel, { - permissions: { - list: [Permissions.IsAny], - create: [Permissions.IsAny], - read: [Permissions.IsAny], - update: [Permissions.IsAny], - delete: [Permissions.IsAny], - }, - queryFilter: (user?: {_id: ObjectId | string; admin: boolean}) => { - if (!user?.admin) { - return {hidden: {$ne: true}}; - } - return {}; - }, - transformer: AdminOwnerTransformer({ - adminReadFields: ["name", "calories", "created", "ownerId"], - adminWriteFields: ["name", "calories", "created", "ownerId"], - ownerReadFields: ["name", "calories", "created", "ownerId"], - ownerWriteFields: ["name", "calories", "created"], - authReadFields: ["name", "calories", "created"], - authWriteFields: ["name", "calories"], - anonReadFields: ["name"], - anonWriteFields: [], - }), - }) - ); - server = supertest(app); - }); - - it("filters list for non-admin", async function () { - const agent = await authAsUser(app, "notAdmin"); - const foodRes = await agent.get("/food").expect(200); - assert.lengthOf(foodRes.body.data, 2); - }); - - it("does not filter list for admin", async function () { - const agent = await authAsUser(app, "admin"); - const foodRes = await agent.get("/food").expect(200); - assert.lengthOf(foodRes.body.data, 3); - }); - - it("admin read transform", async function () { - const agent = await authAsUser(app, "admin"); - const foodRes = await agent.get("/food").expect(200); - assert.lengthOf(foodRes.body.data, 3); - const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); - assert.isDefined(spinach.created); - assert.isDefined(spinach.id); - assert.isDefined(spinach.ownerId); - assert.equal(spinach.name, "Spinach"); - assert.equal(spinach.calories, 1); - assert.isUndefined(spinach.hidden); - }); - - it("admin write transform", async function () { - const agent = await authAsUser(app, "admin"); - const foodRes = await agent.get("/food").expect(200); - const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); - const spinachRes = await agent - .patch(`/food/${spinach.id}`) - .send({name: "Lettuce"}) - .expect(200); - assert.equal(spinachRes.body.data.name, "Lettuce"); - }); - - it("owner read transform", async function () { - const agent = await authAsUser(app, "notAdmin"); - const foodRes = await agent.get("/food").expect(200); - assert.lengthOf(foodRes.body.data, 2); - const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); - assert.isDefined(spinach.id); - assert.equal(spinach.name, "Spinach"); - assert.equal(spinach.calories, 1); - assert.isDefined(spinach.created); - assert.isDefined(spinach.ownerId); - assert.isUndefined(spinach.hidden); - }); - - it("owner write transform", async function () { - const agent = await authAsUser(app, "notAdmin"); - const foodRes = await agent.get("/food").expect(200); - const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); - await agent.patch(`/food/${spinach.id}`).send({ownerId: admin.id}).expect(403); - }); - - it("owner write transform fails", async function () { - const agent = await authAsUser(app, "notAdmin"); - const foodRes = await agent.get("/food").expect(200); - const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); - const spinachRes = await agent - .patch(`/food/${spinach.id}`) - .send({ownerId: notAdmin.id}) - .expect(403); - assert.equal(spinachRes.body.message, "User of type owner cannot write fields: ownerId"); - }); - - it("auth read transform", async function () { - const agent = await authAsUser(app, "notAdmin"); - const foodRes = await agent.get("/food").expect(200); - assert.lengthOf(foodRes.body.data, 2); - const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); - assert.isDefined(spinach.id); - assert.equal(spinach.name, "Spinach"); - assert.equal(spinach.calories, 1); - assert.isDefined(spinach.created); - // Owner, so this is defined. - assert.isDefined(spinach.ownerId); - assert.isUndefined(spinach.hidden); - - const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); - assert.isDefined(carrots.id); - assert.equal(carrots.name, "Carrots"); - assert.equal(carrots.calories, 100); - assert.isDefined(carrots.created); - // Not owner, so undefined. - assert.isUndefined(carrots.ownerId); - assert.isUndefined(spinach.hidden); - }); - - it("auth write transform", async function () { - const agent = await authAsUser(app, "notAdmin"); - const foodRes = await agent.get("/food"); - const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); - const carrotRes = await agent.patch(`/food/${carrots.id}`).send({calories: 2000}).expect(200); - assert.equal(carrotRes.body.data.calories, 2000); - }); - - it("auth write transform fail", async function () { - const agent = await authAsUser(app, "notAdmin"); - const foodRes = await agent.get("/food"); - const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); - const writeRes = await agent - .patch(`/food/${carrots.id}`) - .send({created: "2020-01-01T00:00:00Z"}) - .expect(403); - assert.equal(writeRes.body.message, "User of type auth cannot write fields: created"); - }); - - it("anon read transform", async function () { - const res = await server.get("/food"); - assert.lengthOf(res.body.data, 2); - assert.isDefined(res.body.data.find((f: Food) => f.name === "Spinach")); - assert.isDefined(res.body.data.find((f: Food) => f.name === "Carrots")); - }); - - it("anon write transform fails", async function () { - const foodRes = await server.get("/food"); - const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); - await server.patch(`/food/${carrots.id}`).send({calories: 10}).expect(403); - }); - }); - describe("model array operations", function () { let admin: any; let spinach: Food; @@ -777,7 +252,7 @@ describe("ferns-api", () => { setupAuth(app, UserModel as any); app.use( "/food", - gooseRestRouter(FoodModel, { + fernsRouter(FoodModel, { permissions: { list: [Permissions.IsAdmin], create: [Permissions.IsAdmin], @@ -925,7 +400,7 @@ describe("ferns-api", () => { setupAuth(app, UserModel as any); app.use( "/food", - gooseRestRouter(FoodModel, { + fernsRouter(FoodModel, { permissions: { list: [Permissions.IsAny], create: [Permissions.IsAuthenticated], @@ -1093,7 +568,7 @@ describe("ferns-api", () => { setupAuth(app, UserModel as any); app.use( "/users", - gooseRestRouter(UserModel, { + fernsRouter(UserModel, { permissions: { list: [Permissions.IsAuthenticated], create: [Permissions.IsAuthenticated], @@ -1245,206 +720,3 @@ describe("ferns-api", () => { }); }); }); - -describe("test token auth", function () { - let app: express.Application; - let server: any; - - beforeEach(async function () { - const [admin, notAdmin] = await setupDb(); - - await Promise.all([ - FoodModel.create({ - name: "Spinach", - calories: 1, - created: new Date(), - ownerId: notAdmin._id, - }), - FoodModel.create({ - name: "Apple", - calories: 100, - created: new Date().getTime() - 10, - ownerId: admin._id, - hidden: true, - }), - FoodModel.create({ - name: "Carrots", - calories: 100, - created: new Date().getTime() - 10, - ownerId: admin._id, - }), - ]); - app = getBaseServer(); - setupAuth(app, UserModel as any); - app.use( - "/food", - gooseRestRouter(FoodModel, { - permissions: { - list: [Permissions.IsAny], - create: [Permissions.IsAuthenticated], - read: [Permissions.IsAny], - update: [Permissions.IsAuthenticated], - delete: [Permissions.IsAuthenticated], - }, - queryFilter: (user?: {admin: boolean}) => { - if (!user?.admin) { - return {hidden: {$ne: true}}; - } - return {}; - }, - transformer: AdminOwnerTransformer({ - adminReadFields: ["name", "calories", "created", "ownerId"], - adminWriteFields: ["name", "calories", "created", "ownerId"], - ownerReadFields: ["name", "calories", "created", "ownerId"], - ownerWriteFields: ["name", "calories", "created"], - authReadFields: ["name", "calories", "created"], - authWriteFields: ["name", "calories"], - anonReadFields: ["name"], - anonWriteFields: [], - }), - }) - ); - server = supertest(app); - }); - - it("completes token signup e2e", async function () { - const agent = supertest.agent(app); - let res = await server - .post("/auth/signup") - .send({email: "new@example.com", password: "123"}) - .expect(200); - let {userId, token} = res.body.data; - assert.isDefined(userId); - assert.isDefined(token); - - res = await server - .post("/auth/login") - .send({email: "new@example.com", password: "123"}) - .expect(200); - agent.set("authorization", `Bearer ${res.body.data.token}`); - - userId = res.body.data.userId; - token = res.body.data.token; - assert.isDefined(userId); - assert.isDefined(token); - - const food = await FoodModel.create({ - name: "Peas", - calories: 1, - created: new Date(), - ownerId: userId, - }); - - const meRes = await agent.get("/auth/me").expect(200); - assert.isDefined(meRes.body.data._id); - assert.isDefined(meRes.body.data.id); - assert.isUndefined(meRes.body.data.hash); - assert.equal(meRes.body.data.email, "new@example.com"); - assert.isDefined(meRes.body.data.token); - assert.isDefined(meRes.body.data.updated); - assert.isDefined(meRes.body.data.created); - assert.isFalse(meRes.body.data.admin); - - const mePatchRes = await server - .patch("/auth/me") - .send({email: "new2@example.com"}) - .set("authorization", `Bearer ${token}`) - .expect(200); - assert.isDefined(mePatchRes.body.data._id); - assert.isDefined(mePatchRes.body.data.id); - assert.isUndefined(mePatchRes.body.data.hash); - assert.equal(mePatchRes.body.data.email, "new2@example.com"); - assert.isDefined(mePatchRes.body.data.token); - assert.isDefined(mePatchRes.body.data.updated); - assert.isDefined(mePatchRes.body.data.created); - assert.isFalse(mePatchRes.body.data.admin); - - // Use token to see 2 foods + the one we just created - const getRes = await agent.get("/food").expect(200); - - assert.lengthOf(getRes.body.data, 3); - assert.isDefined(getRes.body.data.find((f: any) => f.name === "Peas")); - - const updateRes = await agent - .patch(`/food/${food._id}`) - .send({name: "PeasAndCarrots"}) - .expect(200); - assert.equal(updateRes.body.data.name, "PeasAndCarrots"); - }); - - it("signup with extra data", async function () { - const res = await server - .post("/auth/signup") - .send({email: "new@example.com", password: "123", age: 25}) - .expect(200); - const {userId, token} = res.body.data; - assert.isDefined(userId); - assert.isDefined(token); - - const user = await UserModel.findOne({email: "new@example.com"}); - assert.equal(user?.age, 25); - }); - - it("login failure", async function () { - let res = await server - .post("/auth/login") - .send({email: "admin@example.com", password: "wrong"}) - .expect(401); - assert.deepEqual(res.body, {message: "Incorrect Password"}); - res = await server - .post("/auth/login") - .send({email: "nope@example.com", password: "wrong"}) - .expect(401); - assert.deepEqual(res.body, {message: "User Not Found"}); - }); - - it("completes token login e2e", async function () { - const agent = supertest.agent(app); - const res = await agent - .post("/auth/login") - .send({email: "admin@example.com", password: "securePassword"}) - .expect(200); - const {userId, token} = res.body.data; - assert.isDefined(userId); - assert.isDefined(token); - - agent.set("authorization", `Bearer ${res.body.data.token}`); - - const meRes = await agent.get("/auth/me").expect(200); - assert.isDefined(meRes.body.data._id); - assert.isDefined(meRes.body.data.id); - assert.isUndefined(meRes.body.data.hash); - assert.equal(meRes.body.data.email, "admin@example.com"); - assert.isDefined(meRes.body.data.token); - assert.isDefined(meRes.body.data.updated); - assert.isDefined(meRes.body.data.created); - assert.isTrue(meRes.body.data.admin); - - const mePatchRes = await agent - .patch("/auth/me") - .send({email: "admin2@example.com"}) - .expect(200); - assert.isDefined(mePatchRes.body.data._id); - assert.isDefined(mePatchRes.body.data.id); - assert.isUndefined(mePatchRes.body.data.hash); - assert.equal(mePatchRes.body.data.email, "admin2@example.com"); - assert.isDefined(mePatchRes.body.data.token); - assert.isDefined(mePatchRes.body.data.updated); - assert.isDefined(mePatchRes.body.data.created); - assert.isTrue(mePatchRes.body.data.admin); - - // Use token to see admin foods - const getRes = await agent.get("/food").expect(200); - - assert.lengthOf(getRes.body.data, 3); - const food = getRes.body.data.find((f: any) => f.name === "Apple"); - assert.isDefined(food); - - const updateRes = await server - .patch(`/food/${food.id}`) - .set("authorization", `Bearer ${token}`) - .send({name: "Apple Pie"}) - .expect(200); - assert.equal(updateRes.body.data.name, "Apple Pie"); - }); -}); diff --git a/src/api.ts b/src/api.ts index 948f018c..9f36d328 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,28 +1,18 @@ +/** + * This is the doc comment for api.ts + * + * @packageDocumentation + */ import express, {NextFunction, Request, Response} from "express"; -import jwt from "jsonwebtoken"; -import mongoose, {Document, Model, ObjectId, Schema} from "mongoose"; -import passport from "passport"; -import {Strategy as AnonymousStrategy} from "passport-anonymous"; -import {Strategy as JwtStrategy} from "passport-jwt"; -import {Strategy as LocalStrategy} from "passport-local"; +import mongoose, {Document, Model} from "mongoose"; +import {authenticateMiddleware, User} from "./auth"; import {APIError, getAPIErrorBody, isAPIError} from "./errors"; import {logger} from "./logger"; +import {checkPermissions, RESTPermissions} from "./permissions"; +import {FernsTransformer, serialize, transform} from "./transformers"; import {isValidObjectId} from "./utils"; -export interface Env { - NODE_ENV?: string; - PORT?: string; - SENTRY_DSN?: string; - SLACK_WEBHOOK?: string; - // JWT - TOKEN_SECRET?: string; - TOKEN_EXPIRES_IN?: string; - TOKEN_ISSUER?: string; - // AUTH - SESSION_SECRET?: string; -} - // TODOS: // Support bulk actions // Support more complex query fields @@ -30,534 +20,113 @@ export interface Env { const SPECIAL_QUERY_PARAMS = ["limit", "page"]; +/** + * @param a - the first number + * @param b - the second number + * @returns The sum of `a` and `b` + */ export type RESTMethod = "list" | "create" | "read" | "update" | "delete"; -interface GooseTransformer { - // Runs before create or update operations. Allows throwing out fields that the user should be - // able to write to, modify data, check permissions, etc. - transform?: (obj: Partial, method: "create" | "update", user?: User) => Partial | undefined; - // Runs after create/update operations but before data is returned from the API. Serialize fetched - // data, dropping fields based on user, changing data, etc. - serialize?: (obj: T, user?: User) => Partial | undefined; -} - -type UserType = "anon" | "auth" | "owner" | "admin"; - -interface User { - _id: ObjectId | string; - id: string; - admin: boolean; - isAnonymous?: boolean; - token?: string; -} - -export interface UserModel extends Model { - createAnonymousUser?: (id?: string) => Promise; - postCreate?: (body: any) => Promise; - - createStrategy(): any; - - serializeUser(): any; - - // Allows additional setup during signup. This will be passed the rest of req.body from the signup - deserializeUser(): any; -} - -export type PermissionMethod = ( - method: RESTMethod, - user?: User, - obj?: T -) => boolean | Promise; - -interface RESTPermissions { - create: PermissionMethod[]; - list: PermissionMethod[]; - read: PermissionMethod[]; - update: PermissionMethod[]; - delete: PermissionMethod[]; -} - -interface GooseRESTOptions { +/** + * This is the main configuration. + * @param T - the base document type. This should not include Mongoose models, just the types of the object. + */ +export interface FernsRouterOptions { + /** + * A group of method-level (create/read/update/delete/list) permissions. Determine if the user can perform the + * operation at all, and for read/update/delete methods, whether the user can perform the operation on the object + * referenced. + * */ permissions: RESTPermissions; + /** A list of fields on the model that can be queried using standard comparisons for booleans, strings, dates + * (as ISOStrings), and numbers. + * For example: + * ?foo=true // boolean query + * ?foo=bar // string query + * ?foo=1 // number query + * ?foo=2022-07-23T02:34:07.118Z // date query (should first be encoded for query params, not shown here) + * Note: `limit` and `page` are automatically supported and are reserved. */ queryFields?: string[]; - // return null to prevent the query from running + /** queryFilter is a function to parse the query params and see if the query should be allowed. This can be used for + * permissioning to make sure less privileged users are not making privileged queries. If a query should not be + * allowed, return `null` from the function and an empty query result will be returned to the client without an error. + * You can also throw an APIError to be explicit about the issues. You can transform the given query params by + * returning different values. If the query is acceptable as-is, return `query` as-is. */ queryFilter?: (user?: User, query?: Record) => Record | null; - transformer?: GooseTransformer; + /** Transformers allow data to be transformed before actions are executed, and serialized before being returned to + * the user. + * + * Transformers can be used to throw out fields that the user should not be able to write to, such as the `admin` flag. + * Serializers can be used to hide data from the client or change how it is presented. Serializers run after the data + * has been changed or queried but before returning to the client. + * */ + transformer?: FernsTransformer; + /** Default sort for list operations. Can be a single field, a space-seperated list of fields, or an object. + * ?sort=foo // single field: foo ascending + * ?sort=-foo // single field: foo descending + * ?sort=-foo bar // multi field: foo descending, bar ascending + * ?sort=\{foo: 'ascending', bar: 'descending'\} // object: foo ascending, bar descending + * + * Note: you should have an index field on these fields or Mongo may slow down considerably. + * @deprecated Use preCreate/preUpdate/preDelete hooks instead of transformer.transform. + * */ sort?: string | {[key: string]: "ascending" | "descending"}; + /** Default queries to provide to Mongo before any user queries or transforms happen when making list queries. + * Accepts any Mongoose-style queries, and runs for all user types. + * defaultQueryParams: \{hidden: false\} // By default, don't show objects with hidden=true + * These can be overridden by the user if not disallowed by queryFilter. */ defaultQueryParams?: {[key: string]: any}; + /** Paths to populate before returning data from list queries. Accepts Mongoose-style populate strings. + * ["ownerId"] // populates the User that matches `ownerId` + * ["ownerId.organizationId"] // Nested. Populates the User that matches `ownerId`, as well as their organization. + * */ populatePaths?: string[]; - defaultLimit?: number; // defaults to 100 + /** Default limit applied to list queries if not specified by the user. Defaults to 100. */ + defaultLimit?: number; + /** Maximum query limit the user can request. Defaults to 500, and is the lowest of the limit query, max limit, + * or 500. */ maxLimit?: number; // defaults to 500 + /** */ endpoints?: (router: any) => void; + /** Hook that runs after `transformer.transform` but before the object is created. Can update the body fields based on + * the request or the user. + * Return null to return a generic 403 + * error. Throw an APIError to return a 400 with specific error information. */ preCreate?: (value: any, request: express.Request) => T | Promise | null; + /** Hook that runs after `transformer.transform` but before changes are made for update operations. Can update the + * body fields based on the request or the user. Also applies to all array operations. + * Return null to return a generic 403 + * error. Throw an APIError to return a 400 with specific error information. */ preUpdate?: (value: any, request: express.Request) => T | Promise | null; + /** Hook that runs after `transformer.transform` but before the object is delete. + * Return null to return a generic 403 + * error. Throw an APIError to return a 400 with specific error information. */ preDelete?: (value: any, request: express.Request) => T | Promise | null; + /** Hook that runs after the object is created but before it is serialized and returned. This is a good spot to + * perform dependent changes to other models or performing async tasks, such as sending a push notification. + * Throw an APIError to return a 400 with an error message. */ postCreate?: (value: T, request: express.Request) => void | Promise; + /** Hook that runs after the object is updated but before it is serialized and returned. This is a good spot to + * perform dependent changes to other models or performing async tasks, such as sending a push notification. + * Throw an APIError to return a 400 with an error message. */ postUpdate?: (value: T, cleanedBody: any, request: express.Request) => void | Promise; + /** Hook that runs after the object is created but before it is serialized and returned. This is a good spot to + * perform dependent changes to other models or performing async tasks, such as cascading object deletions. + * Throw an APIError to return a 400 with an error message. */ postDelete?: (request: express.Request) => void | Promise; - // The discriminatorKey that you passed when creating the Mongoose models. Defaults to __t. See: - // https://mongoosejs.com/docs/discriminators.html + /** The discriminatorKey that you passed when creating the Mongoose models. Defaults to __t. See: + * https://mongoosejs.com/docs/discriminators.html + * If this key is provided, you must provide the same key as part of the top level of the body when making performing + * update or delete operations on this model. + * \{discriminatorKey: "__t"\} + * + * PATCH \{__t: "SuperUser", name: "Foo"\} // __t is required or there will be a 404 error. + */ discriminatorKey?: string; } -export const OwnerQueryFilter = (user?: User) => { - if (user) { - return {ownerId: user?.id}; - } - // Return a null, so we know to return no results. - return null; -}; - -export const Permissions = { - IsAuthenticatedOrReadOnly: (method: RESTMethod, user?: User) => { - if (user?.id && !user?.isAnonymous) { - return true; - } - return method === "list" || method === "read"; - }, - IsOwnerOrReadOnly: (method: RESTMethod, user?: User, obj?: any) => { - // When checking if we can possibly perform the action, return true. - if (!obj) { - return true; - } - if (user?.admin) { - return true; - } - - if (user?.id && obj?.ownerId && String(obj?.ownerId) === String(user?.id)) { - return true; - } - return method === "list" || method === "read"; - }, - IsAny: () => { - return true; - }, - IsOwner: (method: RESTMethod, user?: User, obj?: any) => { - // When checking if we can possibly perform the action, return true. - if (!obj) { - return true; - } - if (!user) { - return false; - } - if (user?.admin) { - return true; - } - return user?.id && obj?.ownerId && String(obj?.ownerId) === String(user?.id); - }, - IsAdmin: (method: RESTMethod, user?: User) => { - return Boolean(user?.admin); - }, - IsAuthenticated: (method: RESTMethod, user?: User) => { - if (!user) { - return false; - } - return Boolean(user.id); - }, -}; - -// Defaults closed -export async function checkPermissions( - method: RESTMethod, - permissions: PermissionMethod[], - user?: User, - obj?: T -): Promise { - let anyTrue = false; - for (const perm of permissions) { - // May or may not be a promise. - if (!(await perm(method, user, obj))) { - return false; - } else { - anyTrue = true; - } - } - return anyTrue; -} - -export function tokenPlugin(schema: Schema) { - schema.add({token: {type: String, index: true}}); - schema.pre("save", function (next) { - // Add created when creating the object - if (!this.token) { - const tokenOptions: any = { - expiresIn: "10h", - }; - if ((process.env as Env).TOKEN_EXPIRES_IN) { - tokenOptions.expiresIn = (process.env as Env).TOKEN_EXPIRES_IN; - } - if ((process.env as Env).TOKEN_ISSUER) { - tokenOptions.issuer = (process.env as Env).TOKEN_ISSUER; - } - - const secretOrKey = (process.env as Env).TOKEN_SECRET; - if (!secretOrKey) { - throw new Error(`TOKEN_SECRET must be set in env.`); - } - this.token = jwt.sign({id: this._id.toString()}, secretOrKey, tokenOptions); - } - // On any save, update the updated field. - this.updated = new Date(); - next(); - }); -} - -export interface BaseUser { - admin: boolean; - email: string; -} - -export function baseUserPlugin(schema: Schema) { - schema.add({admin: {type: Boolean, default: false}}); - schema.add({email: {type: String, index: true}}); -} - -export interface IsDeleted { - deleted: boolean; -} - -export function isDeletedPlugin(schema: Schema, defaultValue = false) { - schema.add({deleted: {type: Boolean, default: defaultValue, index: true}}); - schema.pre("find", function () { - const query = this.getQuery(); - if (query && query.deleted === undefined) { - this.where({deleted: {$ne: true}}); - } - }); -} - -export interface CreatedDeleted { - updated: Date; - created: Date; -} - -export function createdUpdatedPlugin(schema: Schema) { - schema.add({updated: {type: Date, index: true}}); - schema.add({created: {type: Date, index: true}}); - - schema.pre("save", function (next) { - if (this.disableCreatedUpdatedPlugin === true) { - next(); - return; - } - // If we aren't specifying created, use now. - if (!this.created) { - this.created = new Date(); - } - // All writes change the updated time. - this.updated = new Date(); - next(); - }); - - schema.pre("update", function (next) { - this.update({}, {$set: {updated: new Date()}}); - next(); - }); -} - -export function firebaseJWTPlugin(schema: Schema) { - schema.add({firebaseId: {type: String, index: true}}); -} - -export function authenticateMiddleware(anonymous = false) { - const strategies = ["jwt"]; - if (anonymous) { - strategies.push("anonymous"); - } - return passport.authenticate(strategies, {session: false, failureMessage: true}); -} - -export async function signupUser( - userModel: UserModel, - email: string, - password: string, - body?: any -) { - try { - const user = await (userModel as any).register({email}, password); - if (user.postCreate) { - delete body.email; - delete body.password; - try { - await user.postCreate(body); - } catch (e) { - logger.error("Error in user.postCreate", e); - throw e; - } - } - await user.save(); - if (!user.token) { - throw new Error("Token not created"); - } - return user; - } catch (error) { - throw error; - } -} - -// TODO allow customization -export function setupAuth(app: express.Application, userModel: UserModel) { - passport.use(new AnonymousStrategy()); - passport.use( - "signup", - new LocalStrategy( - { - usernameField: "email", - passwordField: "password", - passReqToCallback: true, - }, - async (req, email, password, done) => { - try { - done(undefined, await signupUser(userModel, email, password, req.body)); - } catch (e) { - return done(e); - } - } - ) - ); - - passport.use( - "login", - new LocalStrategy( - { - usernameField: "email", - passwordField: "password", - }, - async (email, password, done) => { - try { - const user = await userModel.findOne({email}); - if (!user) { - logger.warn(`Could not find login user for ${email}`); - return done(null, false, {message: "User Not Found"}); - } - - const validate = await (user as any).authenticate(password); - if (validate.error) { - logger.warn("Invalid password for", email); - return done(null, false, {message: "Incorrect Password"}); - } - - return done(null, user, {message: "Logged in Successfully"}); - } catch (error) { - logger.error("Login error", error); - return done(error); - } - } - ) - ); - - if (!userModel.createStrategy) { - throw new Error("setupAuth userModel must have .createStrategy()"); - } - if (!userModel.serializeUser) { - throw new Error("setupAuth userModel must have .serializeUser()"); - } - if (!userModel.deserializeUser) { - throw new Error("setupAuth userModel must have .deserializeUser()"); - } - - // use static serialize and deserialize of model for passport session support - passport.serializeUser(userModel.serializeUser()); - passport.deserializeUser(userModel.deserializeUser()); - - if ((process.env as Env).TOKEN_SECRET) { - logger.debug("Setting up JWT Authentication"); - - const customExtractor = function (req: express.Request) { - let token = null; - if (req?.cookies?.jwt) { - token = req.cookies.jwt; - } else if (req?.headers?.authorization) { - token = req?.headers?.authorization.split(" ")[1]; - } - return token; - }; - const secretOrKey = (process.env as Env).TOKEN_SECRET; - if (!secretOrKey) { - throw new Error(`TOKEN_SECRET must be set in env.`); - } - const jwtOpts = { - // jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme("Bearer"), - jwtFromRequest: customExtractor, - secretOrKey, - issuer: (process.env as Env).TOKEN_ISSUER, - }; - passport.use( - "jwt", - new JwtStrategy(jwtOpts, async function ( - payload: {id: string; iat: number; exp: number}, - done: any - ) { - let user; - if (!payload) { - return done(null, false); - } - try { - user = await userModel.findById((payload as any).id); - } catch (e) { - logger.warn("[jwt] Error finding user from id", e); - return done(e, false); - } - if (user) { - return done(null, user); - } else { - if (userModel.createAnonymousUser) { - logger.info("[jwt] Creating anonymous user"); - user = await userModel.createAnonymousUser(); - return done(null, user); - } else { - logger.info("[jwt] No user found from token"); - return done(null, false); - } - } - }) - ); - } - - const router = express.Router(); - router.post("/login", function (req, res, next) { - passport.authenticate("login", {session: true}, (err: any, user: any, info: any) => { - if (err) { - logger.error("Error logging in:", err); - return next(err); - } - if (!user) { - logger.warn("Invalid login:", info); - return res.status(401).json({message: info?.message}); - } - return res.json({data: {userId: user?._id, token: (user as any)?.token}}); - })(req, res, next); - }); - - router.post( - "/signup", - passport.authenticate("signup", {session: false, failWithError: true}), - async function (req: any, res: any) { - return res.json({data: {userId: req.user._id, token: req.user.token}}); - } - ); - - router.get("/me", authenticateMiddleware(), async (req, res) => { - if (!req.user?.id) { - logger.debug("Not user found for /me"); - return res.sendStatus(401); - } - const data = await userModel.findById(req.user.id); - - if (!data) { - logger.debug("Not user data found for /me"); - return res.sendStatus(404); - } - const dataObject = data.toObject(); - (dataObject as any).id = data._id; - return res.json({data: dataObject}); - }); - - router.patch("/me", authenticateMiddleware(), async (req, res) => { - if (!req.user?.id) { - return res.sendStatus(401); - } - const doc = await userModel.findById(req.user.id); - if (!doc) { - return res.sendStatus(404); - } - // TODO support limited updates for profile. - // try { - // body = transform(req.body, "update", req.user); - // } catch (e) { - // return res.status(403).send({message: (e as any).message}); - // } - try { - Object.assign(doc, req.body); - await doc.save(); - - const dataObject = doc.toObject(); - (dataObject as any).id = doc._id; - return res.json({data: dataObject}); - } catch (e) { - return res.status(403).send({message: (e as any).message}); - } - }); - - app.use(express.urlencoded({extended: false}) as any); - app.use(passport.initialize() as any); - - app.set("etag", false); - app.use("/auth", router); -} - -function getUserType(user?: User, obj?: any): UserType { - if (user?.admin) { - return "admin"; - } - if (obj && user && String(obj?.ownerId) === String(user?.id)) { - return "owner"; - } - if (user?.id) { - return "auth"; - } - return "anon"; -} - -export function AdminOwnerTransformer(options: { - // TODO: do something with KeyOf here. - anonReadFields?: string[]; - authReadFields?: string[]; - ownerReadFields?: string[]; - adminReadFields?: string[]; - anonWriteFields?: string[]; - authWriteFields?: string[]; - ownerWriteFields?: string[]; - adminWriteFields?: string[]; -}): GooseTransformer { - function pickFields(obj: Partial, fields: any[]): Partial { - const newData: Partial = {}; - for (const field of fields) { - if (obj[field] !== undefined) { - newData[field] = obj[field]; - } - } - return newData; - } - - return { - transform: (obj: Partial, method: "create" | "update", user?: User) => { - const userType = getUserType(user, obj); - let allowedFields: any; - if (userType === "admin") { - allowedFields = options.adminWriteFields ?? []; - } else if (userType === "owner") { - allowedFields = options.ownerWriteFields ?? []; - } else if (userType === "auth") { - allowedFields = options.authWriteFields ?? []; - } else { - allowedFields = options.anonWriteFields ?? []; - } - const unallowedFields = Object.keys(obj).filter((k) => !allowedFields.includes(k)); - if (unallowedFields.length) { - throw new Error( - `User of type ${userType} cannot write fields: ${unallowedFields.join(", ")}` - ); - } - return obj; - }, - serialize: (obj: T, user?: User) => { - const userType = getUserType(user, obj); - if (userType === "admin") { - return pickFields(obj, [...(options.adminReadFields ?? []), "id"]); - } else if (userType === "owner") { - return pickFields(obj, [...(options.ownerReadFields ?? []), "id"]); - } else if (userType === "auth") { - return pickFields(obj, [...(options.authReadFields ?? []), "id"]); - } else { - return pickFields(obj, [...(options.anonReadFields ?? []), "id"]); - } - }, - }; -} - // A function to decide which model to use. If no discriminators are provided, just returns the base model. If -function getModel(baseModel: Model, body?: any, options?: GooseRESTOptions) { +function getModel(baseModel: Model, body?: any, options?: FernsRouterOptions) { const discriminatorKey = options?.discriminatorKey ?? "__t"; const modelName = (body ?? {})[discriminatorKey]; if (!modelName) { @@ -573,46 +142,18 @@ function getModel(baseModel: Model, body?: any, options?: GooseRESTOptions< } } -export function gooseRestRouter( +/** + * Create a set of CRUD routes given a Mongoose model $baseModel and configuration options. + * + * @param baseModel A Mongoose Model + * @param options Options for configuring the REST API, such as permissions, transformers, and hooks. + */ +export function fernsRouter( baseModel: Model, - options: GooseRESTOptions + options: FernsRouterOptions ): express.Router { const router = express.Router(); - function transform(data: Partial | Partial[], method: "create" | "update", user?: User) { - if (!options.transformer?.transform) { - return data; - } - - // TS doesn't realize this is defined otherwise... - const transformFn = options.transformer?.transform; - - if (!Array.isArray(data)) { - return transformFn(data, method, user); - } else { - return data.map((d) => transformFn(d, method, user)); - } - } - - function serialize(data: Document | Document[], user?: User) { - const serializeFn = (serializeData: Document, seralizeUser?: User) => { - const dataObject = serializeData.toObject() as T; - (dataObject as any).id = serializeData._id; - - if (options.transformer?.serialize) { - return options.transformer?.serialize(dataObject, seralizeUser); - } else { - return dataObject; - } - }; - - if (!Array.isArray(data)) { - return serializeFn(data, user); - } else { - return data.map((d) => serializeFn(d, user)); - } - } - // Do before the other router options so endpoints take priority. if (options.endpoints) { options.endpoints(router); @@ -628,7 +169,7 @@ export function gooseRestRouter( let body; try { - body = transform(req.body, "create", req.user); + body = transform(options, req.body, "create", req.user); } catch (e) { return res.status(403).send({message: (e as any).message}); } @@ -656,7 +197,7 @@ export function gooseRestRouter( } } // @ts-ignore TS being overprotective of data since we are using generics - return res.status(201).json({data: serialize(data, req.user)}); + return res.status(201).json({data: serialize(options, data, req.user)}); }); // TODO add rate limit @@ -701,6 +242,7 @@ export function gooseRestRouter( mongoose.connection.db.collection(model.collection.collectionName); } + // Check if any of the keys in the query are not allowed by options.queryFilter if (options.queryFilter) { let queryFilter; try { @@ -753,7 +295,7 @@ export function gooseRestRouter( } let more; try { - let serialized = serialize(data, req.user); + let serialized = serialize(options, data, req.user); if (serialized && Array.isArray(serialized)) { more = serialized.length === limit + 1 && serialized.length > 0; if (more) { @@ -790,7 +332,7 @@ export function gooseRestRouter( return res.sendStatus(403); } - return res.json({data: serialize(data, req.user)}); + return res.json({data: serialize(options, data, req.user)}); }); router.put("/:id", authenticateMiddleware(true), async (req, res) => { @@ -820,7 +362,7 @@ export function gooseRestRouter( let body; try { - body = transform(req.body, "update", req.user); + body = transform(options, req.body, "update", req.user); } catch (e) { logger.warn( `PATCH failed on ${req.params.id} for user ${req.user?.id}: ${(e as any).message}` @@ -863,7 +405,7 @@ export function gooseRestRouter( .send({message: `PATCH Post Update error on ${req.params.id}: ${(e as any).message}`}); } } - return res.json({data: serialize(doc, req.user)}); + return res.json({data: serialize(options, doc, req.user)}); }); router.delete("/:id", authenticateMiddleware(true), async (req, res) => { @@ -1001,7 +543,7 @@ export function gooseRestRouter( let body: Partial | null = {[field]: array} as unknown as Partial; try { - body = transform(body, "update", req.user) as Partial; + body = transform(options, body, "update", req.user) as Partial; } catch (e) { throw new APIError({ title: (e as any).message, @@ -1048,7 +590,7 @@ export function gooseRestRouter( }); } } - return res.json({data: serialize(doc, req.user)}); + return res.json({data: serialize(options, doc, req.user)}); } async function arrayPost(req: Request, res: Response) { @@ -1084,3 +626,7 @@ function apiErrorMiddleware(err: Error, req: Request, res: Response, next: NextF const asyncHandler = (fn: any) => (req: Request, res: Response, next: NextFunction) => { return Promise.resolve(fn(req, res, next)).catch(next); }; + +// For backwards compatibility with the old names. +export const gooseRestRouter = fernsRouter; +export type GooseRESTOptions = FernsRouterOptions; diff --git a/src/auth.test.ts b/src/auth.test.ts new file mode 100644 index 00000000..eddfed7f --- /dev/null +++ b/src/auth.test.ts @@ -0,0 +1,212 @@ +import {assert} from "chai"; +import express from "express"; +import supertest from "supertest"; + +import {fernsRouter} from "./api"; +import {setupAuth} from "./auth"; +import {Permissions} from "./permissions"; +import {Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests"; +import {AdminOwnerTransformer} from "./transformers"; + +describe("auth tests", function () { + let app: express.Application; + let server: any; + + beforeEach(async function () { + const [admin, notAdmin] = await setupDb(); + + await Promise.all([ + FoodModel.create({ + name: "Spinach", + calories: 1, + created: new Date(), + ownerId: notAdmin._id, + }), + FoodModel.create({ + name: "Apple", + calories: 100, + created: new Date().getTime() - 10, + ownerId: admin._id, + hidden: true, + }), + FoodModel.create({ + name: "Carrots", + calories: 100, + created: new Date().getTime() - 10, + ownerId: admin._id, + }), + ]); + app = getBaseServer(); + setupAuth(app, UserModel as any); + app.use( + "/food", + fernsRouter(FoodModel, { + permissions: { + list: [Permissions.IsAny], + create: [Permissions.IsAuthenticated], + read: [Permissions.IsAny], + update: [Permissions.IsAuthenticated], + delete: [Permissions.IsAuthenticated], + }, + queryFilter: (user?: {admin: boolean}) => { + if (!user?.admin) { + return {hidden: {$ne: true}}; + } + return {}; + }, + transformer: AdminOwnerTransformer({ + adminReadFields: ["name", "calories", "created", "ownerId"], + adminWriteFields: ["name", "calories", "created", "ownerId"], + ownerReadFields: ["name", "calories", "created", "ownerId"], + ownerWriteFields: ["name", "calories", "created"], + authReadFields: ["name", "calories", "created"], + authWriteFields: ["name", "calories"], + anonReadFields: ["name"], + anonWriteFields: [], + }), + }) + ); + server = supertest(app); + }); + + it("completes token signup e2e", async function () { + const agent = supertest.agent(app); + let res = await server + .post("/auth/signup") + .send({email: "new@example.com", password: "123"}) + .expect(200); + let {userId, token} = res.body.data; + assert.isDefined(userId); + assert.isDefined(token); + + res = await server + .post("/auth/login") + .send({email: "new@example.com", password: "123"}) + .expect(200); + agent.set("authorization", `Bearer ${res.body.data.token}`); + + userId = res.body.data.userId; + token = res.body.data.token; + assert.isDefined(userId); + assert.isDefined(token); + + const food = await FoodModel.create({ + name: "Peas", + calories: 1, + created: new Date(), + ownerId: userId, + }); + + const meRes = await agent.get("/auth/me").expect(200); + assert.isDefined(meRes.body.data._id); + assert.isDefined(meRes.body.data.id); + assert.isUndefined(meRes.body.data.hash); + assert.equal(meRes.body.data.email, "new@example.com"); + assert.isDefined(meRes.body.data.token); + assert.isDefined(meRes.body.data.updated); + assert.isDefined(meRes.body.data.created); + assert.isFalse(meRes.body.data.admin); + + const mePatchRes = await server + .patch("/auth/me") + .send({email: "new2@example.com"}) + .set("authorization", `Bearer ${token}`) + .expect(200); + assert.isDefined(mePatchRes.body.data._id); + assert.isDefined(mePatchRes.body.data.id); + assert.isUndefined(mePatchRes.body.data.hash); + assert.equal(mePatchRes.body.data.email, "new2@example.com"); + assert.isDefined(mePatchRes.body.data.token); + assert.isDefined(mePatchRes.body.data.updated); + assert.isDefined(mePatchRes.body.data.created); + assert.isFalse(mePatchRes.body.data.admin); + + // Use token to see 2 foods + the one we just created + const getRes = await agent.get("/food").expect(200); + + assert.lengthOf(getRes.body.data, 3); + assert.isDefined(getRes.body.data.find((f: any) => f.name === "Peas")); + + const updateRes = await agent + .patch(`/food/${food._id}`) + .send({name: "PeasAndCarrots"}) + .expect(200); + assert.equal(updateRes.body.data.name, "PeasAndCarrots"); + }); + + it("signup with extra data", async function () { + const res = await server + .post("/auth/signup") + .send({email: "new@example.com", password: "123", age: 25}) + .expect(200); + const {userId, token} = res.body.data; + assert.isDefined(userId); + assert.isDefined(token); + + const user = await UserModel.findOne({email: "new@example.com"}); + assert.equal(user?.age, 25); + }); + + it("login failure", async function () { + let res = await server + .post("/auth/login") + .send({email: "admin@example.com", password: "wrong"}) + .expect(401); + assert.deepEqual(res.body, {message: "Incorrect Password"}); + res = await server + .post("/auth/login") + .send({email: "nope@example.com", password: "wrong"}) + .expect(401); + assert.deepEqual(res.body, {message: "User Not Found"}); + }); + + it("completes token login e2e", async function () { + const agent = supertest.agent(app); + const res = await agent + .post("/auth/login") + .send({email: "admin@example.com", password: "securePassword"}) + .expect(200); + const {userId, token} = res.body.data; + assert.isDefined(userId); + assert.isDefined(token); + + agent.set("authorization", `Bearer ${res.body.data.token}`); + + const meRes = await agent.get("/auth/me").expect(200); + assert.isDefined(meRes.body.data._id); + assert.isDefined(meRes.body.data.id); + assert.isUndefined(meRes.body.data.hash); + assert.equal(meRes.body.data.email, "admin@example.com"); + assert.isDefined(meRes.body.data.token); + assert.isDefined(meRes.body.data.updated); + assert.isDefined(meRes.body.data.created); + assert.isTrue(meRes.body.data.admin); + + const mePatchRes = await agent + .patch("/auth/me") + .send({email: "admin2@example.com"}) + .expect(200); + assert.isDefined(mePatchRes.body.data._id); + assert.isDefined(mePatchRes.body.data.id); + assert.isUndefined(mePatchRes.body.data.hash); + assert.equal(mePatchRes.body.data.email, "admin2@example.com"); + assert.isDefined(mePatchRes.body.data.token); + assert.isDefined(mePatchRes.body.data.updated); + assert.isDefined(mePatchRes.body.data.created); + assert.isTrue(mePatchRes.body.data.admin); + + // Use token to see admin foods + const getRes = await agent.get("/food").expect(200); + + assert.lengthOf(getRes.body.data, 3); + const food = getRes.body.data.find((f: any) => f.name === "Apple"); + assert.isDefined(food); + + const updateRes = await server + .patch(`/food/${food.id}`) + .set("authorization", `Bearer ${token}`) + .send({name: "Apple Pie"}) + .expect(200); + assert.equal(updateRes.body.data.name, "Apple Pie"); + }); +}); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 00000000..ed1e8c5c --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,258 @@ +import express from "express"; +import {Model, ObjectId} from "mongoose"; +import passport from "passport"; +import {Strategy as AnonymousStrategy} from "passport-anonymous"; +import {Strategy as JwtStrategy} from "passport-jwt"; +import {Strategy as LocalStrategy} from "passport-local"; + +import {logger} from "./logger"; + +export interface User { + _id: ObjectId | string; + id: string; + // Whether the user should be treated as an admin or not. Admins can have extra abilities in permissions + // declarations + admin: boolean; + /** We support anonymous users, which do not yet have login information. This can be helpful for pre-signup users. */ + isAnonymous?: boolean; + token?: string; +} + +export interface UserModel extends Model { + createAnonymousUser?: (id?: string) => Promise; + postCreate?: (body: any) => Promise; + + createStrategy(): any; + + serializeUser(): any; + + // Allows additional setup during signup. This will be passed the rest of req.body from the signup + deserializeUser(): any; +} + +export function authenticateMiddleware(anonymous = false) { + const strategies = ["jwt"]; + if (anonymous) { + strategies.push("anonymous"); + } + return passport.authenticate(strategies, {session: false, failureMessage: true}); +} + +export async function signupUser( + userModel: UserModel, + email: string, + password: string, + body?: any +) { + try { + const user = await (userModel as any).register({email}, password); + if (user.postCreate) { + delete body.email; + delete body.password; + try { + await user.postCreate(body); + } catch (e) { + logger.error("Error in user.postCreate", e); + throw e; + } + } + await user.save(); + if (!user.token) { + throw new Error("Token not created"); + } + return user; + } catch (error) { + throw error; + } +} + +// TODO allow customization +export function setupAuth(app: express.Application, userModel: UserModel) { + passport.use(new AnonymousStrategy()); + passport.use( + "signup", + new LocalStrategy( + { + usernameField: "email", + passwordField: "password", + passReqToCallback: true, + }, + async (req, email, password, done) => { + try { + done(undefined, await signupUser(userModel, email, password, req.body)); + } catch (e) { + return done(e); + } + } + ) + ); + + passport.use( + "login", + new LocalStrategy( + { + usernameField: "email", + passwordField: "password", + }, + async (email, password, done) => { + try { + const user = await userModel.findOne({email}); + if (!user) { + logger.warn(`Could not find login user for ${email}`); + return done(null, false, {message: "User Not Found"}); + } + + const validate = await (user as any).authenticate(password); + if (validate.error) { + logger.warn("Invalid password for", email); + return done(null, false, {message: "Incorrect Password"}); + } + + return done(null, user, {message: "Logged in Successfully"}); + } catch (error) { + logger.error("Login error", error); + return done(error); + } + } + ) + ); + + if (!userModel.createStrategy) { + throw new Error("setupAuth userModel must have .createStrategy()"); + } + if (!userModel.serializeUser) { + throw new Error("setupAuth userModel must have .serializeUser()"); + } + if (!userModel.deserializeUser) { + throw new Error("setupAuth userModel must have .deserializeUser()"); + } + + // use static serialize and deserialize of model for passport session support + passport.serializeUser(userModel.serializeUser()); + passport.deserializeUser(userModel.deserializeUser()); + + if (process.env.TOKEN_SECRET) { + logger.debug("Setting up JWT Authentication"); + + const customExtractor = function (req: express.Request) { + let token = null; + if (req?.cookies?.jwt) { + token = req.cookies.jwt; + } else if (req?.headers?.authorization) { + token = req?.headers?.authorization.split(" ")[1]; + } + return token; + }; + const secretOrKey = process.env.TOKEN_SECRET; + if (!secretOrKey) { + throw new Error(`TOKEN_SECRET must be set in env.`); + } + const jwtOpts = { + // jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme("Bearer"), + jwtFromRequest: customExtractor, + secretOrKey, + issuer: process.env.TOKEN_ISSUER, + }; + passport.use( + "jwt", + new JwtStrategy(jwtOpts, async function ( + payload: {id: string; iat: number; exp: number}, + done: any + ) { + let user; + if (!payload) { + return done(null, false); + } + try { + user = await userModel.findById((payload as any).id); + } catch (e) { + logger.warn("[jwt] Error finding user from id", e); + return done(e, false); + } + if (user) { + return done(null, user); + } else { + if (userModel.createAnonymousUser) { + logger.info("[jwt] Creating anonymous user"); + user = await userModel.createAnonymousUser(); + return done(null, user); + } else { + logger.info("[jwt] No user found from token"); + return done(null, false); + } + } + }) + ); + } + + const router = express.Router(); + router.post("/login", function (req, res, next) { + passport.authenticate("login", {session: true}, (err: any, user: any, info: any) => { + if (err) { + logger.error("Error logging in:", err); + return next(err); + } + if (!user) { + logger.warn("Invalid login:", info); + return res.status(401).json({message: info?.message}); + } + return res.json({data: {userId: user?._id, token: (user as any)?.token}}); + })(req, res, next); + }); + + router.post( + "/signup", + passport.authenticate("signup", {session: false, failWithError: true}), + async function (req: any, res: any) { + return res.json({data: {userId: req.user._id, token: req.user.token}}); + } + ); + + router.get("/me", authenticateMiddleware(), async (req, res) => { + if (!req.user?.id) { + logger.debug("Not user found for /me"); + return res.sendStatus(401); + } + const data = await userModel.findById(req.user.id); + + if (!data) { + logger.debug("Not user data found for /me"); + return res.sendStatus(404); + } + const dataObject = data.toObject(); + (dataObject as any).id = data._id; + return res.json({data: dataObject}); + }); + + router.patch("/me", authenticateMiddleware(), async (req, res) => { + if (!req.user?.id) { + return res.sendStatus(401); + } + const doc = await userModel.findById(req.user.id); + if (!doc) { + return res.sendStatus(404); + } + // TODO support limited updates for profile. + // try { + // body = transform(req.body, "update", req.user); + // } catch (e) { + // return res.status(403).send({message: (e as any).message}); + // } + try { + Object.assign(doc, req.body); + await doc.save(); + + const dataObject = doc.toObject(); + (dataObject as any).id = doc._id; + return res.json({data: dataObject}); + } catch (e) { + return res.status(403).send({message: (e as any).message}); + } + }); + + app.use(express.urlencoded({extended: false}) as any); + app.use(passport.initialize() as any); + + app.set("etag", false); + app.use("/auth", router); +} diff --git a/src/errors.ts b/src/errors.ts index 9114c132..1317544f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,3 @@ -// This can be attached to any schema to store errors compatible with the JSONAPI spec. // https://jsonapi.org/format/#errors import {Schema} from "mongoose"; @@ -39,6 +38,19 @@ export interface APIErrorConstructor { meta?: {[id: string]: string}; } +/** + * APIError is a simple way to throw an error in an API route and control what is shown and the HTTP code displayed. + * It follows the JSONAPI spec to standardize the fields, allowing the UI to show more consistent, better error messages. + * + * ```ts + * throw new APIError({ + * title: "Only an admin can update that!", + * status: 403, + * code: "update-admin-error", + * detail: "You must be an admin to change that field" + * }); + * ``` + */ export class APIError extends Error { title: string; @@ -96,6 +108,7 @@ export class APIError extends Error { } } +// This can be attached to any schema to store errors compatible with the JSONAPI spec. export const ErrorSchema = new Schema({ title: {type: String, required: true}, id: String, diff --git a/src/example.ts b/src/example.ts index dae74d7a..8d3e5148 100644 --- a/src/example.ts +++ b/src/example.ts @@ -1,9 +1,12 @@ import express from "express"; import mongoose, {model, Schema} from "mongoose"; -import {logger, tokenPlugin} from "."; -import {baseUserPlugin, createdUpdatedPlugin, gooseRestRouter, Permissions, setupAuth} from "./api"; +import {fernsRouter} from "./api"; +import {setupAuth} from "./auth"; +import {logger} from "./logger"; import {passportLocalMongoose} from "./passport"; +import {Permissions} from "./permissions"; +import {baseUserPlugin, createdUpdatedPlugin, tokenPlugin} from "./plugins"; mongoose.connect("mongodb://localhost:27017/example"); @@ -58,7 +61,7 @@ function getBaseServer() { setupAuth(app, UserModel as any); app.use( "/food", - gooseRestRouter(FoodModel, { + fernsRouter(FoodModel, { permissions: { list: [Permissions.IsAny], create: [Permissions.IsAuthenticated], diff --git a/src/expressServer.ts b/src/expressServer.ts index 48802058..e375c654 100644 --- a/src/expressServer.ts +++ b/src/expressServer.ts @@ -7,14 +7,14 @@ import cloneDeep from "lodash/cloneDeep"; import onFinished from "on-finished"; import passport from "passport"; -import {Env, setupAuth, UserModel as UserMongooseModel} from "./api"; +import {setupAuth, UserModel as UserMongooseModel} from "./auth"; import {logger, LoggingOptions, setupLogging} from "./logger"; const SLOW_READ_MAX = 200; const SLOW_WRITE_MAX = 500; export function setupErrorLogging() { - const dsn = (process.env as Env).SENTRY_DSN; + const dsn = process.env.SENTRY_DSN; if (process.env.NODE_ENV === "production") { if (!dsn) { throw new Error("You must set SENTRY_DSN in the environment."); @@ -226,7 +226,7 @@ export function cronjob( // Convenience method to send data to a Slack webhook. export async function sendToSlack(text: string, channel = "bots") { - const slackWebhookUrl = (process.env as Env).SLACK_WEBHOOK; + const slackWebhookUrl = process.env.SLACK_WEBHOOK; if (!slackWebhookUrl) { throw new Error("You must set SLACK_WEBHOOK in the environment."); } diff --git a/src/index.ts b/src/index.ts index ce54a085..29736d3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ export * from "./api"; +export * from "./auth"; export * from "./errors"; export * from "./expressServer"; export * from "./logger"; export * from "./passport"; +export * from "./permissions"; +export * from "./transformers"; export * from "./utils"; diff --git a/src/permissions.test.ts b/src/permissions.test.ts new file mode 100644 index 00000000..28e0dc84 --- /dev/null +++ b/src/permissions.test.ts @@ -0,0 +1,213 @@ +import {assert} from "chai"; +import express from "express"; +import supertest from "supertest"; + +import {fernsRouter} from "./api"; +import {setupAuth} from "./auth"; +import {Permissions} from "./permissions"; +import { + authAsUser, + Food, + FoodModel, + getBaseServer, + RequiredModel, + setupDb, + UserModel, +} from "./tests"; + +describe("permissions", function () { + let server: supertest.SuperTest; + let app: express.Application; + + beforeEach(async function () { + const [admin, notAdmin] = await setupDb(); + + await Promise.all([ + FoodModel.create({ + name: "Spinach", + calories: 1, + created: new Date(), + ownerId: notAdmin._id, + }), + FoodModel.create({ + name: "Apple", + calories: 100, + created: new Date().getTime() - 10, + ownerId: admin._id, + }), + ]); + app = getBaseServer(); + setupAuth(app, UserModel as any); + app.use( + "/food", + fernsRouter(FoodModel, { + permissions: { + list: [Permissions.IsAny], + create: [Permissions.IsAuthenticated], + read: [Permissions.IsAny], + update: [Permissions.IsOwner], + delete: [Permissions.IsAdmin], + }, + }) + ); + app.use( + "/required", + fernsRouter(RequiredModel, { + permissions: { + list: [Permissions.IsAny], + create: [Permissions.IsAuthenticated], + read: [Permissions.IsAny], + update: [Permissions.IsOwner], + delete: [Permissions.IsAdmin], + }, + }) + ); + server = supertest(app); + }); + + describe("anonymous food", function () { + it("list", async function () { + const res = await server.get("/food").expect(200); + assert.lengthOf(res.body.data, 2); + }); + + it("get", async function () { + const res = await server.get("/food").expect(200); + assert.lengthOf(res.body.data, 2); + const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200); + assert.equal(res.body.data[0]._id, res2.body.data._id); + }); + + it("post", async function () { + const res = await server.post("/food").send({ + name: "Broccoli", + calories: 15, + }); + assert.equal(res.status, 405); + }); + + it("patch", async function () { + const res = await server.get("/food"); + const res2 = await server.patch(`/food/${res.body.data[0]._id}`).send({ + name: "Broccoli", + }); + assert.equal(res2.status, 403); + }); + + it("delete", async function () { + const res = await server.get("/food"); + const res2 = await server.delete(`/food/${res.body.data[0]._id}`); + assert.equal(res2.status, 405); + }); + }); + + describe("non admin food", function () { + let agent: supertest.SuperAgentTest; + beforeEach(async function () { + agent = await authAsUser(app, "notAdmin"); + }); + + it("list", async function () { + const res = await agent.get("/food").expect(200); + assert.lengthOf(res.body.data, 2); + }); + + it("get", async function () { + const res = await agent.get("/food").expect(200); + assert.lengthOf(res.body.data, 2); + const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200); + assert.equal(res.body.data[0]._id, res2.body.data._id); + }); + + it("post", async function () { + await agent + .post("/food") + .send({ + name: "Broccoli", + calories: 15, + }) + .expect(201); + }); + + it("patch own item", async function () { + const res = await agent.get("/food"); + const spinach = res.body.data.find((food: Food) => food.name === "Spinach"); + const res2 = await agent + .patch(`/food/${spinach._id}`) + .send({ + name: "Broccoli", + }) + .expect(200); + assert.equal(res2.body.data.name, "Broccoli"); + }); + + it("patch other item", async function () { + const res = await agent.get("/food"); + const spinach = res.body.data.find((food: Food) => food.name === "Apple"); + await agent + .patch(`/food/${spinach._id}`) + .send({ + name: "Broccoli", + }) + .expect(403); + }); + + it("delete", async function () { + const res = await agent.get("/food"); + const res2 = await agent.delete(`/food/${res.body.data[0]._id}`); + assert.equal(res2.status, 405); + }); + }); + + describe("admin food", function () { + let agent: supertest.SuperAgentTest; + + beforeEach(async function () { + agent = await authAsUser(app, "admin"); + }); + + it("list", async function () { + const res = await agent.get("/food"); + assert.lengthOf(res.body.data, 2); + }); + + it("get", async function () { + const res = await agent.get("/food"); + assert.lengthOf(res.body.data, 2); + const res2 = await agent.get(`/food/${res.body.data[0]._id}`); + assert.equal(res.body.data[0]._id, res2.body.data._id); + }); + + it("post", async function () { + const res = await agent.post("/food").send({ + name: "Broccoli", + calories: 15, + }); + assert.equal(res.status, 201); + }); + + it("patch", async function () { + const res = await agent.get("/food"); + await agent + .patch(`/food/${res.body.data[0]._id}`) + .send({ + name: "Broccoli", + }) + .expect(200); + }); + + it("delete", async function () { + const res = await agent.get("/food"); + await agent.delete(`/food/${res.body.data[0]._id}`).expect(204); + }); + + it("handles validation errors", async function () { + await agent + .post("/required") + .send({ + about: "Whoops forgot required", + }) + .expect(400); + }); + }); +}); diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 00000000..e6cb06e3 --- /dev/null +++ b/src/permissions.ts @@ -0,0 +1,91 @@ +// Defaults closed +import {RESTMethod} from "./api"; +import {User} from "./auth"; + +export type PermissionMethod = ( + method: RESTMethod, + user?: User, + obj?: T +) => boolean | Promise; + +export interface RESTPermissions { + create: PermissionMethod[]; + list: PermissionMethod[]; + read: PermissionMethod[]; + update: PermissionMethod[]; + delete: PermissionMethod[]; +} + +export const OwnerQueryFilter = (user?: User) => { + if (user) { + return {ownerId: user?.id}; + } + // Return a null, so we know to return no results. + return null; +}; + +export const Permissions = { + IsAuthenticatedOrReadOnly: (method: RESTMethod, user?: User) => { + if (user?.id && !user?.isAnonymous) { + return true; + } + return method === "list" || method === "read"; + }, + IsOwnerOrReadOnly: (method: RESTMethod, user?: User, obj?: any) => { + // When checking if we can possibly perform the action, return true. + if (!obj) { + return true; + } + if (user?.admin) { + return true; + } + + if (user?.id && obj?.ownerId && String(obj?.ownerId) === String(user?.id)) { + return true; + } + return method === "list" || method === "read"; + }, + IsAny: () => { + return true; + }, + IsOwner: (method: RESTMethod, user?: User, obj?: any) => { + // When checking if we can possibly perform the action, return true. + if (!obj) { + return true; + } + if (!user) { + return false; + } + if (user?.admin) { + return true; + } + return user?.id && obj?.ownerId && String(obj?.ownerId) === String(user?.id); + }, + IsAdmin: (method: RESTMethod, user?: User) => { + return Boolean(user?.admin); + }, + IsAuthenticated: (method: RESTMethod, user?: User) => { + if (!user) { + return false; + } + return Boolean(user.id); + }, +}; + +export async function checkPermissions( + method: RESTMethod, + permissions: PermissionMethod[], + user?: User, + obj?: T +): Promise { + let anyTrue = false; + for (const perm of permissions) { + // May or may not be a promise. + if (!(await perm(method, user, obj))) { + return false; + } else { + anyTrue = true; + } + } + return anyTrue; +} diff --git a/src/plugins.ts b/src/plugins.ts new file mode 100644 index 00000000..b5c025ef --- /dev/null +++ b/src/plugins.ts @@ -0,0 +1,88 @@ +import jwt from "jsonwebtoken"; +import {Schema} from "mongoose"; + +export function tokenPlugin(schema: Schema) { + schema.add({token: {type: String, index: true}}); + schema.pre("save", function (next) { + // Add created when creating the object + if (!this.token) { + const tokenOptions: any = { + expiresIn: "10h", + }; + if (process.env.TOKEN_EXPIRES_IN) { + tokenOptions.expiresIn = process.env.TOKEN_EXPIRES_IN; + } + if (process.env.TOKEN_ISSUER) { + tokenOptions.issuer = process.env.TOKEN_ISSUER; + } + + const secretOrKey = process.env.TOKEN_SECRET; + if (!secretOrKey) { + throw new Error(`TOKEN_SECRET must be set in env.`); + } + this.token = jwt.sign({id: this._id.toString()}, secretOrKey, tokenOptions); + } + // On any save, update the updated field. + this.updated = new Date(); + next(); + }); +} + +export interface BaseUser { + admin: boolean; + email: string; +} + +export function baseUserPlugin(schema: Schema) { + schema.add({admin: {type: Boolean, default: false}}); + schema.add({email: {type: String, index: true}}); +} + +/** For models with the isDeletedPlugin, extend this interface to add the appropriate fields. */ +export interface IsDeleted { + // Whether the model should be treated as deleted or not. + deleted: boolean; +} + +export function isDeletedPlugin(schema: Schema, defaultValue = false) { + schema.add({deleted: {type: Boolean, default: defaultValue, index: true}}); + schema.pre("find", function () { + const query = this.getQuery(); + if (query && query.deleted === undefined) { + this.where({deleted: {$ne: true}}); + } + }); +} + +export interface CreatedDeleted { + updated: Date; + created: Date; +} + +export function createdUpdatedPlugin(schema: Schema) { + schema.add({updated: {type: Date, index: true}}); + schema.add({created: {type: Date, index: true}}); + + schema.pre("save", function (next) { + if (this.disableCreatedUpdatedPlugin === true) { + next(); + return; + } + // If we aren't specifying created, use now. + if (!this.created) { + this.created = new Date(); + } + // All writes change the updated time. + this.updated = new Date(); + next(); + }); + + schema.pre("update", function (next) { + this.update({}, {$set: {updated: new Date()}}); + next(); + }); +} + +export function firebaseJWTPlugin(schema: Schema) { + schema.add({firebaseId: {type: String, index: true}}); +} diff --git a/src/tests.ts b/src/tests.ts new file mode 100644 index 00000000..f36576a7 --- /dev/null +++ b/src/tests.ts @@ -0,0 +1,159 @@ +import express, {Express} from "express"; +import mongoose, {model, Schema} from "mongoose"; +import supertest from "supertest"; + +import {passportLocalMongoose} from "./passport"; +import {createdUpdatedPlugin, tokenPlugin} from "./plugins"; + +mongoose.connect("mongodb://localhost:27017/ferns"); + +export interface User { + admin: boolean; + username: string; + email: string; + age?: number; +} + +export interface SuperUser extends User { + superTitle: string; +} + +export interface StaffUser extends User { + department: string; +} + +export interface FoodCategory { + _id?: string; + name: string; + show: boolean; +} + +export interface Food { + _id: string; + name: string; + calories: number; + created: Date; + ownerId: mongoose.Types.ObjectId | User; + hidden?: boolean; + source: { + name: string; + }; + tags: string[]; + categories: FoodCategory[]; +} + +const userSchema = new Schema({ + username: String, + admin: {type: Boolean, default: false}, + age: Number, +}); + +userSchema.plugin(passportLocalMongoose, {usernameField: "email"}); +userSchema.plugin(tokenPlugin); +userSchema.plugin(createdUpdatedPlugin); +userSchema.methods.postCreate = async function (body: any) { + this.age = body.age; + return this.save(); +}; + +export const UserModel = model("User", userSchema); + +const superUserSchema = new Schema({ + superTitle: {type: String, required: true}, +}); +export const SuperUserModel = UserModel.discriminator("SuperUser", superUserSchema); + +const staffUserSchema = new Schema({ + department: {type: String, required: true}, +}); +export const StaffUserModel = UserModel.discriminator("Staff", staffUserSchema); + +const foodCategorySchema = new Schema({ + name: String, + show: Boolean, +}); + +const foodSchema = new Schema({ + name: String, + calories: Number, + created: Date, + ownerId: {type: "ObjectId", ref: "User"}, + source: { + name: String, + }, + hidden: {type: Boolean, default: false}, + tags: [String], + categories: [foodCategorySchema], +}); + +export const FoodModel = model("Food", foodSchema); + +interface RequiredField { + name: string; + about?: string; +} + +const requiredSchema = new Schema({ + name: {type: String, required: true}, + about: String, +}); +export const RequiredModel = model("Required", requiredSchema); + +export function getBaseServer(): Express { + const app = express(); + + app.all("/*", function (req, res, next) { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "*"); + // intercepts OPTIONS method + if (req.method === "OPTIONS") { + res.send(200); + } else { + next(); + } + }); + app.use(express.json()); + return app; +} + +afterAll(() => { + mongoose.connection.close(); +}); + +export async function authAsUser( + app: express.Application, + type: "admin" | "notAdmin" +): Promise { + const email = type === "admin" ? "admin@example.com" : "notAdmin@example.com"; + const password = type === "admin" ? "securePassword" : "password"; + + const agent = supertest.agent(app); + const res = await agent.post("/auth/login").send({email, password}).expect(200); + agent.set("authorization", `Bearer ${res.body.data.token}`); + return agent; +} + +export async function setupDb() { + process.env.TOKEN_SECRET = "secret"; + process.env.TOKEN_EXPIRES_IN = "30m"; + process.env.TOKEN_ISSUER = "example.com"; + process.env.SESSION_SECRET = "session"; + + try { + await Promise.all([UserModel.deleteMany({}), FoodModel.deleteMany({})]); + const [notAdmin, admin] = await Promise.all([ + UserModel.create({email: "notAdmin@example.com"}), + UserModel.create({email: "admin@example.com", admin: true}), + ]); + await (notAdmin as any).setPassword("password"); + await notAdmin.save(); + + await (admin as any).setPassword("securePassword"); + await admin.save(); + + return [admin, notAdmin]; + } catch (e) { + console.error("Error setting up DB", e); + throw e; + } +} diff --git a/src/transformers.test.ts b/src/transformers.test.ts new file mode 100644 index 00000000..69fd8e2a --- /dev/null +++ b/src/transformers.test.ts @@ -0,0 +1,193 @@ +import {assert} from "chai"; +import express from "express"; +import {ObjectId} from "mongoose"; +import supertest from "supertest"; + +import {fernsRouter} from "./api"; +import {setupAuth} from "./auth"; +import {Permissions} from "./permissions"; +import {authAsUser, Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests"; +import {AdminOwnerTransformer} from "./transformers"; + +describe("query and transform", function () { + let notAdmin: any; + let admin: any; + let server: supertest.SuperTest; + let app: express.Application; + + beforeEach(async function () { + [admin, notAdmin] = await setupDb(); + + await Promise.all([ + FoodModel.create({ + name: "Spinach", + calories: 1, + created: new Date(), + ownerId: notAdmin._id, + }), + FoodModel.create({ + name: "Apple", + calories: 100, + created: new Date().getTime() - 10, + ownerId: admin._id, + hidden: true, + }), + FoodModel.create({ + name: "Carrots", + calories: 100, + created: new Date().getTime() - 10, + ownerId: admin._id, + }), + ]); + app = getBaseServer(); + setupAuth(app, UserModel as any); + app.use( + "/food", + fernsRouter(FoodModel, { + permissions: { + list: [Permissions.IsAny], + create: [Permissions.IsAny], + read: [Permissions.IsAny], + update: [Permissions.IsAny], + delete: [Permissions.IsAny], + }, + queryFilter: (user?: {_id: ObjectId | string; admin: boolean}) => { + if (!user?.admin) { + return {hidden: {$ne: true}}; + } + return {}; + }, + transformer: AdminOwnerTransformer({ + adminReadFields: ["name", "calories", "created", "ownerId"], + adminWriteFields: ["name", "calories", "created", "ownerId"], + ownerReadFields: ["name", "calories", "created", "ownerId"], + ownerWriteFields: ["name", "calories", "created"], + authReadFields: ["name", "calories", "created"], + authWriteFields: ["name", "calories"], + anonReadFields: ["name"], + anonWriteFields: [], + }), + }) + ); + server = supertest(app); + }); + + it("filters list for non-admin", async function () { + const agent = await authAsUser(app, "notAdmin"); + const foodRes = await agent.get("/food").expect(200); + assert.lengthOf(foodRes.body.data, 2); + }); + + it("does not filter list for admin", async function () { + const agent = await authAsUser(app, "admin"); + const foodRes = await agent.get("/food").expect(200); + assert.lengthOf(foodRes.body.data, 3); + }); + + it("admin read transform", async function () { + const agent = await authAsUser(app, "admin"); + const foodRes = await agent.get("/food").expect(200); + assert.lengthOf(foodRes.body.data, 3); + const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); + assert.isDefined(spinach.created); + assert.isDefined(spinach.id); + assert.isDefined(spinach.ownerId); + assert.equal(spinach.name, "Spinach"); + assert.equal(spinach.calories, 1); + assert.isUndefined(spinach.hidden); + }); + + it("admin write transform", async function () { + const agent = await authAsUser(app, "admin"); + const foodRes = await agent.get("/food").expect(200); + const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); + const spinachRes = await agent.patch(`/food/${spinach.id}`).send({name: "Lettuce"}).expect(200); + assert.equal(spinachRes.body.data.name, "Lettuce"); + }); + + it("owner read transform", async function () { + const agent = await authAsUser(app, "notAdmin"); + const foodRes = await agent.get("/food").expect(200); + assert.lengthOf(foodRes.body.data, 2); + const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); + assert.isDefined(spinach.id); + assert.equal(spinach.name, "Spinach"); + assert.equal(spinach.calories, 1); + assert.isDefined(spinach.created); + assert.isDefined(spinach.ownerId); + assert.isUndefined(spinach.hidden); + }); + + it("owner write transform", async function () { + const agent = await authAsUser(app, "notAdmin"); + const foodRes = await agent.get("/food").expect(200); + const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); + await agent.patch(`/food/${spinach.id}`).send({ownerId: admin.id}).expect(403); + }); + + it("owner write transform fails", async function () { + const agent = await authAsUser(app, "notAdmin"); + const foodRes = await agent.get("/food").expect(200); + const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); + const spinachRes = await agent + .patch(`/food/${spinach.id}`) + .send({ownerId: notAdmin.id}) + .expect(403); + assert.equal(spinachRes.body.message, "User of type owner cannot write fields: ownerId"); + }); + + it("auth read transform", async function () { + const agent = await authAsUser(app, "notAdmin"); + const foodRes = await agent.get("/food").expect(200); + assert.lengthOf(foodRes.body.data, 2); + const spinach = foodRes.body.data.find((food: Food) => food.name === "Spinach"); + assert.isDefined(spinach.id); + assert.equal(spinach.name, "Spinach"); + assert.equal(spinach.calories, 1); + assert.isDefined(spinach.created); + // Owner, so this is defined. + assert.isDefined(spinach.ownerId); + assert.isUndefined(spinach.hidden); + + const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); + assert.isDefined(carrots.id); + assert.equal(carrots.name, "Carrots"); + assert.equal(carrots.calories, 100); + assert.isDefined(carrots.created); + // Not owner, so undefined. + assert.isUndefined(carrots.ownerId); + assert.isUndefined(spinach.hidden); + }); + + it("auth write transform", async function () { + const agent = await authAsUser(app, "notAdmin"); + const foodRes = await agent.get("/food"); + const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); + const carrotRes = await agent.patch(`/food/${carrots.id}`).send({calories: 2000}).expect(200); + assert.equal(carrotRes.body.data.calories, 2000); + }); + + it("auth write transform fail", async function () { + const agent = await authAsUser(app, "notAdmin"); + const foodRes = await agent.get("/food"); + const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); + const writeRes = await agent + .patch(`/food/${carrots.id}`) + .send({created: "2020-01-01T00:00:00Z"}) + .expect(403); + assert.equal(writeRes.body.message, "User of type auth cannot write fields: created"); + }); + + it("anon read transform", async function () { + const res = await server.get("/food"); + assert.lengthOf(res.body.data, 2); + assert.isDefined(res.body.data.find((f: Food) => f.name === "Spinach")); + assert.isDefined(res.body.data.find((f: Food) => f.name === "Carrots")); + }); + + it("anon write transform fails", async function () { + const foodRes = await server.get("/food"); + const carrots = foodRes.body.data.find((food: Food) => food.name === "Carrots"); + await server.patch(`/food/${carrots.id}`).send({calories: 10}).expect(403); + }); +}); diff --git a/src/transformers.ts b/src/transformers.ts new file mode 100644 index 00000000..ecac654c --- /dev/null +++ b/src/transformers.ts @@ -0,0 +1,132 @@ +import {Document} from "mongoose"; + +import {FernsRouterOptions} from "./api"; +import {User} from "./auth"; +import {logger} from "./logger"; + +export interface FernsTransformer { + // Runs before create or update operations. Allows throwing out fields that the user should be + // able to write to, modify data, check permissions, etc. + transform?: (obj: Partial, method: "create" | "update", user?: User) => Partial | undefined; + // Runs after create/update operations but before data is returned from the API. Serialize fetched + // data, dropping fields based on user, changing data, etc. + serialize?: (obj: T, user?: User) => Partial | undefined; +} + +function getUserType(user?: User, obj?: any): "anon" | "auth" | "owner" | "admin" { + if (user?.admin) { + return "admin"; + } + if (obj && user && String(obj?.ownerId) === String(user?.id)) { + return "owner"; + } + if (user?.id) { + return "auth"; + } + return "anon"; +} + +export function AdminOwnerTransformer(options: { + // TODO: do something with KeyOf here. + anonReadFields?: string[]; + authReadFields?: string[]; + ownerReadFields?: string[]; + adminReadFields?: string[]; + anonWriteFields?: string[]; + authWriteFields?: string[]; + ownerWriteFields?: string[]; + adminWriteFields?: string[]; +}): FernsTransformer { + function pickFields(obj: Partial, fields: any[]): Partial { + const newData: Partial = {}; + for (const field of fields) { + if (obj[field] !== undefined) { + newData[field] = obj[field]; + } + } + return newData; + } + + return { + // TODO: Migrate AdminOwnerTransform to use pre-hooks. + transform: (obj: Partial, method: "create" | "update", user?: User) => { + const userType = getUserType(user, obj); + let allowedFields: any; + if (userType === "admin") { + allowedFields = options.adminWriteFields ?? []; + } else if (userType === "owner") { + allowedFields = options.ownerWriteFields ?? []; + } else if (userType === "auth") { + allowedFields = options.authWriteFields ?? []; + } else { + allowedFields = options.anonWriteFields ?? []; + } + const unallowedFields = Object.keys(obj).filter((k) => !allowedFields.includes(k)); + if (unallowedFields.length) { + throw new Error( + `User of type ${userType} cannot write fields: ${unallowedFields.join(", ")}` + ); + } + return obj; + }, + serialize: (obj: T, user?: User) => { + const userType = getUserType(user, obj); + if (userType === "admin") { + return pickFields(obj, [...(options.adminReadFields ?? []), "id"]); + } else if (userType === "owner") { + return pickFields(obj, [...(options.ownerReadFields ?? []), "id"]); + } else if (userType === "auth") { + return pickFields(obj, [...(options.authReadFields ?? []), "id"]); + } else { + return pickFields(obj, [...(options.anonReadFields ?? []), "id"]); + } + }, + }; +} + +export function transform( + options: FernsRouterOptions, + data: Partial | Partial[], + method: "create" | "update", + user?: User +) { + if (!options.transformer?.transform) { + return data; + } + + logger.warn( + "transform functions are deprecated, use preCreate/preUpdate/preDelete hooks instead" + ); + + // TS doesn't realize this is defined otherwise... + const transformFn = options.transformer?.transform; + + if (!Array.isArray(data)) { + return transformFn(data, method, user); + } else { + return data.map((d) => transformFn(d, method, user)); + } +} + +export function serialize( + options: FernsRouterOptions, + data: Document | Document[], + user?: User +) { + const serializeFn = (serializeData: Document, seralizeUser?: User) => { + const dataObject = serializeData.toObject() as T; + (dataObject as any).id = serializeData._id; + + if (options.transformer?.serialize) { + return options.transformer?.serialize(dataObject, seralizeUser); + } else { + return dataObject; + } + }; + + if (!Array.isArray(data)) { + return serializeFn(data, user); + } else { + return data.map((d) => serializeFn(d, user)); + } +} diff --git a/yarn.lock b/yarn.lock index 1fb493d4..a5e47691 100644 --- a/yarn.lock +++ b/yarn.lock @@ -956,13 +956,6 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express-session@^1.17.4": - version "1.17.4" - resolved "https://registry.yarnpkg.com/@types/express-session/-/express-session-1.17.4.tgz#97a30a35e853a61bdd26e727453b8ed314d6166b" - integrity sha512-7cNlSI8+oOBUHTfPXMwDxF/Lchx5aJ3ho7+p9jJZYVg9dVDJFh3qdMXmJtRsysnvS+C6x46k9DRYmrmCkE+MVg== - dependencies: - "@types/express" "*" - "@types/express@*", "@types/express@^4.17.8": version "4.17.13" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" @@ -1514,6 +1507,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" @@ -1769,16 +1769,16 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= -cookie@0.4.2, cookie@^0.4.1: - version "0.4.2" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" - integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== - cookie@0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + cookiejar@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc" @@ -1869,7 +1869,7 @@ denque@^2.0.1: resolved "https://registry.yarnpkg.com/denque/-/denque-2.0.1.tgz#bcef4c1b80dc32efe97515744f21a4229ab8934a" integrity sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ== -depd@2.0.0, depd@~2.0.0: +depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -2312,20 +2312,6 @@ expo-server-sdk@^3.6.0: node-fetch "^2.6.0" promise-limit "^2.7.0" -express-session@^1.17.2: - version "1.17.3" - resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.3.tgz#14b997a15ed43e5949cb1d073725675dd2777f36" - integrity sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw== - dependencies: - cookie "0.4.2" - cookie-signature "1.0.6" - debug "2.6.9" - depd "~2.0.0" - on-headers "~1.0.2" - parseurl "~1.3.3" - safe-buffer "5.2.1" - uid-safe "~2.1.5" - express@^4.17.1: version "4.18.1" resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" @@ -3380,6 +3366,11 @@ json5@^2.2.1: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== +jsonc-parser@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.1.0.tgz#73b8f0e5c940b83d03476bc2e51a20ef0932615d" + integrity sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg== + jsonwebtoken@^8.2.0, jsonwebtoken@^8.5.1: version "8.5.1" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" @@ -3566,6 +3557,11 @@ lru_map@^0.3.3: resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" integrity sha1-tcg1G5Rky9dQM1p5ZQoOwOVhGN0= +lunr@^2.3.9: + version "2.3.9" + resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" + integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow== + luxon@^1.23.x: version "1.28.0" resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf" @@ -3590,6 +3586,11 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +marked@^4.0.16: + version "4.0.18" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.18.tgz#cd0ac54b2e5610cfb90e8fd46ccaa8292c9ed569" + integrity sha512-wbLDJ7Zh0sqA0Vdg6aqlbT+yPxqLblpAZh1mK2+AO2twQkPywvvqQNfEPVwSSRjZ7dZcdeVBIAgiO7MMp3Dszw== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -3662,6 +3663,13 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7" + integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" @@ -3844,11 +3852,6 @@ on-finished@2.4.1, on-finished@^2.3.0: dependencies: ee-first "1.1.1" -on-headers@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" - integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== - once@1.4.0, once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -4131,11 +4134,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -random-bytes@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" - integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= - range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -4342,6 +4340,15 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +shiki@^0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.10.1.tgz#6f9a16205a823b56c072d0f1a0bcd0f2646bef14" + integrity sha512-VsY7QJVzU51j5o1+DguUd+6vmCmZ5v/6gYu4vyYAhzjuNQU6P/vmSy4uQaOhvje031qQMiW0d2BwgMH52vqMng== + dependencies: + jsonc-parser "^3.0.0" + vscode-oniguruma "^1.6.1" + vscode-textmate "5.2.0" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" @@ -4724,18 +4731,21 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typedoc@~0.23.0: + version "0.23.8" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.8.tgz#6837fb2732f73e7aa61b46a4fbe77a301473998d" + integrity sha512-NLRTY/7XSrhiowR3xnH/nlfTnHk+dkzhHWAMT8guoZ6RHCQZIu3pJREMCqzdkWVCC5+dr9We7TtNeprR3Qy6Ag== + dependencies: + lunr "^2.3.9" + marked "^4.0.16" + minimatch "^5.1.0" + shiki "^0.10.1" + typescript@4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.5.tgz#123a3b214aaff3be32926f0d8f1f6e704eb89a72" integrity sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA== -uid-safe@~2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" - integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== - dependencies: - random-bytes "~1.0.0" - unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -4787,6 +4797,16 @@ vary@^1, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= +vscode-oniguruma@^1.6.1: + version "1.6.2" + resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz#aeb9771a2f1dbfc9083c8a7fdd9cccaa3f386607" + integrity sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA== + +vscode-textmate@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-5.2.0.tgz#01f01760a391e8222fe4f33fbccbd1ad71aed74e" + integrity sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ== + walker@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f"