Playground
Switch one on and see what it does
Nothing is graded here and the defect is named before you start. This is where to come when you want to understand a class rather than be tested on one.
Handlers
| 1 | |
| 2 | /** The password every fixture account uses. Fixtures, not secrets. */ |
| 3 | const FIXTURE_PASSWORD = "correct horse"; |
| 4 | |
| 5 | export function signIn(req: Req, ctx: Ctx) { |
| 6 | const email = String(req.body.email ?? ""); |
| 7 | const password = String(req.body.password ?? ""); |
| 8 | if (email === "" || password === "") { |
| 9 | return ctx.res.status(400).json({ error: "email and password are required" }); |
| 10 | } |
| 11 | |
| 12 | const user = ctx.db.queryOne("SELECT id, role, name FROM users WHERE email = ?", [email]); |
| 13 | // The same answer either way, so a caller cannot tell an unknown address |
| 14 | // from a wrong password. |
| 15 | if (!user || password !== FIXTURE_PASSWORD) { |
| 16 | return ctx.res.status(401).json({ error: "email or password is wrong" }); |
| 17 | } |
| 18 | |
| 19 | // A new session id at the privilege change, so an id planted beforehand is |
| 20 | // not the one that ends up authenticated. |
| 21 | ctx.session.rotate(String(user.id), [String(user.role)]); |
| 22 | ctx.session.attach(ctx.res, { httpOnly: true, secure: true, sameSite: "Lax", path: "/" }); |
| 23 | ctx.log.info("sign-in", { userId: user.id }); |
| 24 | return ctx.res.json({ id: user.id, name: user.name, role: user.role }); |
| 25 | } |
| 26 | |
| 27 | export function sessionToken(req: Req, ctx: Ctx) { |
| 28 | const session = ctx.session.current(); |
| 29 | if (!session || !session.subject) return ctx.res.status(401).json({ error: "sign in" }); |
| 30 | return ctx.res.json({ token: ctx.session.csrfToken() }); |
| 31 | } |
| 32 | |
| 33 | export function signOut(req: Req, ctx: Ctx) { |
| 34 | ctx.session.destroy(); |
| 35 | return ctx.res.status(204).text(""); |
| 36 | } |
| 37 | |
| 38 | export function listOrders(req: Req, ctx: Ctx) { |
| 39 | const session = ctx.session.current(); |
| 40 | if (!session || !session.subject) return ctx.res.status(401).json({ error: "sign in" }); |
| 41 | |
| 42 | const status = req.query.status; |
| 43 | const rows = status |
| 44 | ? ctx.db.query( |
| 45 | "SELECT id, total, status, placed_at FROM orders WHERE user_id = ? AND status = ? ORDER BY placed_at DESC", |
| 46 | [session.subject, status], |
| 47 | ) |
| 48 | : ctx.db.query("SELECT id, total, status, placed_at FROM orders WHERE user_id = ? ORDER BY placed_at DESC", [ |
| 49 | session.subject, |
| 50 | ]); |
| 51 | |
| 52 | return ctx.res.json({ orders: rows }); |
| 53 | } |
| 54 | |
| 55 | export function searchOrders(req: Req, ctx: Ctx) { |
| 56 | const session = ctx.session.current(); |
| 57 | if (!session || !session.subject) return ctx.res.status(401).json({ error: "sign in" }); |
| 58 | |
| 59 | const term = String(req.query.q || ""); |
| 60 | const rows = term |
| 61 | ? ctx.db.query("SELECT id, total, status FROM orders WHERE user_id = ? AND status LIKE ?", [ |
| 62 | session.subject, |
| 63 | "%" + term + "%", |
| 64 | ]) |
| 65 | : []; |
| 66 | |
| 67 | return ctx.res.html(ctx.render("search", { term: term, count: rows.length })); |
| 68 | } |
| 69 | |
| 70 | export function getOrder(req: Req, ctx: Ctx) { |
| 71 | const session = ctx.session.current(); |
| 72 | if (!session || !session.subject) return ctx.res.status(401).json({ error: "sign in" }); |
| 73 | |
| 74 | const order = ctx.db.queryOne("SELECT id, user_id, total, status, placed_at FROM orders WHERE id = ?", [ |
| 75 | req.params.id, |
| 76 | ]); |
| 77 | if (!order) return ctx.res.status(404).json({ error: "no such order" }); |
| 78 | |
| 79 | // Who it belongs to, not just who is asking. |
| 80 | if (String(order.user_id) !== session.subject) { |
| 81 | return ctx.res.status(404).json({ error: "no such order" }); |
| 82 | } |
| 83 | ctx.touched({ table: "orders", id: String(order.id), ownerId: String(order.user_id), kind: "read" }); |
| 84 | |
| 85 | return ctx.res.json({ order: { id: order.id, total: order.total, status: order.status, placedAt: order.placed_at } }); |
| 86 | } |
| 87 | |
| 88 | export function orderPage(req: Req, ctx: Ctx) { |
| 89 | const session = ctx.session.current(); |
| 90 | if (!session || !session.subject) return ctx.res.status(401).json({ error: "sign in" }); |
| 91 | |
| 92 | const order = ctx.db.queryOne("SELECT id, user_id, total, status FROM orders WHERE id = ?", [req.params.id]); |
| 93 | if (!order || String(order.user_id) !== session.subject) { |
| 94 | return ctx.res.status(404).json({ error: "no such order" }); |
| 95 | } |
| 96 | ctx.touched({ table: "orders", id: String(order.id), ownerId: String(order.user_id), kind: "read" }); |
| 97 | |
| 98 | const note = ctx.db.queryOne("SELECT body FROM notes WHERE order_id = ?", [String(order.id)]); |
| 99 | return ctx.res.html(ctx.render("order", { order: order, note: note ? note.body : "" })); |
| 100 | } |
| 101 | |
| 102 | export function updateOrder(req: Req, ctx: Ctx) { |
| 103 | const session = ctx.session.current(); |
| 104 | if (!session || !session.subject) return ctx.res.status(401).json({ error: "sign in" }); |
| 105 | |
| 106 | // The cookie says who you are. It does not say that you meant to send this. |
| 107 | if (!ctx.session.checkCsrf(req.headers["x-csrf-token"])) { |
| 108 | return ctx.res.status(403).json({ error: "bad or missing token" }); |
| 109 | } |
| 110 | |
| 111 | const order = ctx.db.queryOne("SELECT id, user_id FROM orders WHERE id = ?", [req.params.id]); |
| 112 | if (!order || String(order.user_id) !== session.subject) { |
| 113 | return ctx.res.status(404).json({ error: "no such order" }); |
| 114 | } |
| 115 | |
| 116 | // Only the field this route declares. A body carrying anything else is |
| 117 | // ignored rather than trusted. |
| 118 | const status = req.body.status; |
| 119 | if (typeof status !== "string" || status === "") { |
| 120 | return ctx.res.status(400).json({ error: "status is required" }); |
| 121 | } |
| 122 | |
| 123 | ctx.db.execute("UPDATE orders SET status = ? WHERE id = ?", [status, String(order.id)]); |
| 124 | ctx.touched({ |
| 125 | table: "orders", |
| 126 | id: String(order.id), |
| 127 | ownerId: String(order.user_id), |
| 128 | kind: "write", |
| 129 | fields: ["status"], |
| 130 | writable: ["status"], |
| 131 | }); |
| 132 | return ctx.res.json({ id: order.id, status: status }); |
| 133 | } |
| 134 | |
| 135 | export function listUsers(req: Req, ctx: Ctx) { |
| 136 | const session = ctx.session.current(); |
| 137 | if (!session || !session.subject) return ctx.res.status(401).json({ error: "sign in" }); |
| 138 | |
| 139 | // Who you are is not whether you may. |
| 140 | if (session.roles.indexOf("admin") === -1) { |
| 141 | return ctx.res.status(403).json({ error: "not allowed" }); |
| 142 | } |
| 143 | |
| 144 | const rows = ctx.db.query("SELECT id, email, role, name FROM users ORDER BY id"); |
| 145 | return ctx.res.json({ users: rows }); |
| 146 | } |
| 147 | |
| 148 | /* The shapes the framework hands a handler. Declarations, not behaviour. */ |
| 149 | |
| 150 | interface Ctx { |
| 151 | db: { |
| 152 | query(sql: string, params?: unknown[]): Record<string, unknown>[]; |
| 153 | queryOne(sql: string, params?: unknown[]): Record<string, unknown> | undefined; |
| 154 | execute(sql: string, params?: unknown[]): Record<string, unknown>[]; |
| 155 | }; |
| 156 | session: { |
| 157 | current(): { id: string; subject: string | null; roles: string[] } | undefined; |
| 158 | rotate(subject: string, roles: string[]): { id: string }; |
| 159 | authenticate(subject: string, roles: string[]): { id: string }; |
| 160 | destroy(): void; |
| 161 | attach(res: unknown, options?: Record<string, unknown>): void; |
| 162 | csrfToken(): string; |
| 163 | checkCsrf(token: string | undefined): boolean; |
| 164 | }; |
| 165 | res: { |
| 166 | status(code: number): Ctx["res"]; |
| 167 | json(value: unknown): unknown; |
| 168 | html(value: string): unknown; |
| 169 | text(value: string): unknown; |
| 170 | }; |
| 171 | log: { info(message: string, fields?: Record<string, unknown>): void }; |
| 172 | render(template: string, values: Record<string, unknown>): string; |
| 173 | touched(access: Record<string, unknown>): void; |
| 174 | now(): number; |
| 175 | } |
| 176 | |
| 177 | interface Req { |
| 178 | method: string; |
| 179 | path: string; |
| 180 | query: Record<string, string>; |
| 181 | params: Record<string, string>; |
| 182 | body: Record<string, unknown>; |
| 183 | headers: Record<string, string>; |
| 184 | } |
| 185 |
Console
shop.example.com
Nothing sent yet. Every request goes to the application in this tab and nowhere else.
Cookies
Empty. Anything the application sets lands here, and you can edit it.