Public: anyone can read this; the crew can change it.
Identity comes from the forge: /admin opens for a forge admin and the password sign-in is gone #13
Check · Review · Land · Live done — Landed and live in production.
mason/bazaar:mason/9-identity-comes-from-the-forge-admin-open → main 3 commits fork 1 thread · 1 unresolved
19 files
changed +415
−180
AGENTS.md +4 −1
| @@ -31,7 +31,10 @@ preview at its own address, deployed from your branch as you push. | ||
| 31 | 31 | escaped; every response carries the security floor in `src/server.ts` |
| 32 | 32 | (keep it, extend it); a POST that is not same-origin is refused; secrets |
| 33 | 33 | come from the environment, never the repository; no `eval`, no shell |
| 34 | built from user text; a private thing needs a session in an HttpOnly cookie. | |
| 34 | built from user text. Who is asking comes from the forge's signed headers, | |
| 35 | verified in `src/who.ts` and read only as `ctx.who` — never a password of | |
| 36 | our own, never a query parameter; the session cookie is HttpOnly and | |
| 37 | carries the CSRF token. | |
| 35 | 38 | - Speed: the first paint is server-rendered HTML; JavaScript is small, in |
| 36 | 39 | files under `static/` named by content and cached for a year; no layout |
| 37 | 40 | shift; the common request answers in milliseconds; work that can wait is |
README.md +30 −6
| @@ -5,8 +5,8 @@ variants and stock, a cart in a signed cookie, discount codes and gift cards, | ||
| 5 | 5 | tax by where the parcel goes, checkout with an address, a payment step, |
| 6 | 6 | receipts, and an `/admin` for products, inventory, codes, cards, tax and |
| 7 | 7 | orders with refunds. Every price is an integer number of cents, every form |
| 8 | carries a CSRF token, and checkout, payment, typing a code and admin sign-in | |
| 9 | are rate limited. | |
| 8 | carries a CSRF token, and checkout, payment and typing a code are rate | |
| 9 | limited. | |
| 10 | 10 | |
| 11 | 11 | This is an app: the forge runs it. Production follows the default branch; |
| 12 | 12 | every open pull gets a preview at its own address with its own copy of the |
| @@ -94,14 +94,36 @@ already been refunded. | ||
| 94 | 94 | |
| 95 | 95 | | Variable | Meaning | |
| 96 | 96 | | --- | --- | |
| 97 | | `ADMIN_PASSWORD` | The `/admin` password. When it is unset the app generates one and keeps it, and tells only the two places that are already privileged: the run log at boot, and the app's terminal. It is never printed on a page. | | |
| 97 | | `FORGE_APP_IDENTITY_KEY` | The key the forge signs `x-forge-identity` with, verified in `src/who.ts`. Unset means no request can be verified: everybody is anonymous and `/admin` is closed to everybody. There is no password and no way around this. | | |
| 98 | 98 | | `APP_SECRET` | Signs cart cookies, sessions, CSRF tokens and order links. Generated into `FORGE_APP_DATA/secret` when unset. | |
| 99 | 99 | | `STRIPE_SECRET_KEY` | A Stripe **test** key turns on real Stripe Checkout. | |
| 100 | 100 | | `STRIPE_WEBHOOK_SECRET` | Verifies `POST /webhooks/stripe`. | |
| 101 | 101 | |
| 102 | 102 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 103 | 103 | `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it |
| 104 | starts the app. `forge.json` says how. | |
| 104 | starts the app, and `FORGE_APP_USER`, `FORGE_APP_USER_ID` and | |
| 105 | `FORGE_APP_USER_ROLE` when it opens the terminal. `forge.json` says how. | |
| 106 | ||
| 107 | ## Who can do what | |
| 108 | ||
| 109 | Identity is the forge's to assert and ours to verify. On every request the | |
| 110 | forge sends `x-forge-identity` — `<userId>|<username>|<role>|<expiresMs>` — | |
| 111 | and `x-forge-signature`, base64url of HMAC-SHA256 over the identity with | |
| 112 | `FORGE_APP_IDENTITY_KEY`. `src/who.ts` recomputes that signature and compares | |
| 113 | it in constant time; anything it cannot verify is `{ role: "anon" }`. The | |
| 114 | session cookie is still there, but it only carries the CSRF token now. | |
| 115 | ||
| 116 | | Who | What | | |
| 117 | | --- | --- | | |
| 118 | | **anon** | Reads the shop: catalog, search, product pages, the cart, and an order with the order's own token. | | |
| 119 | | **user** | Buys: everything anon can do, and the header names them. | | |
| 120 | | **admin** | Runs the shop: every path under `/admin`, and any order without its token. | | |
| 121 | ||
| 122 | Anyone who is not an admin gets one page at `/admin`, status 403, whose only | |
| 123 | action is **Sign in** at `/__forge/signin?to=<the path they asked for>`; the | |
| 124 | site header offers the same link, and **Sign out** at `/__forge/signout` once | |
| 125 | somebody is signed in. The terminal takes the same three roles from the | |
| 126 | environment and keeps stock moves and refunds for an admin. | |
| 105 | 127 | |
| 106 | 128 | ## Shape |
| 107 | 129 | |
| @@ -113,7 +135,8 @@ starts the app. `forge.json` says how. | ||
| 113 | 135 | | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds | |
| 114 | 136 | | `src/cart.ts` `src/codes.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, integer cents, HMACs | |
| 115 | 137 | | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is | |
| 116 | | `src/session.ts` `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Sessions and CSRF, token buckets, Stripe or the mock, the outbox | | |
| 138 | | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token | | |
| 139 | | `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Token buckets, Stripe or the mock, the outbox | | |
| 117 | 140 | | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` | |
| 118 | 141 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 119 | 142 | |
| @@ -123,4 +146,5 @@ The same shop over the same data. The exact command is in `FORGE_APP_SSH` | ||
| 123 | 146 | when the app runs; the footer of every page prints it verbatim, and says the |
| 124 | 147 | terminal is off when it is unset. `1/2/3` switch between stock, orders and the |
| 125 | 148 | outbox, `j`/`k` move, `s` moves stock with a reason, `r` refunds the rest of an |
| 126 | order and puts it back on the shelf, `q` leaves. | |
| 149 | order and puts it back on the shelf, `q` leaves. The first line says who the | |
| 150 | forge named at the keyboard; `s` and `r` are an admin's. |
src/admin.ts +18 −25
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * /admin: products, inventory, orders, refunds. |
| 3 | 3 | * |
| 4 | * Everything here is behind a session marked admin, and every write is a | |
| 5 | * POST with the session's CSRF token. An admin can see any order and give | |
| 6 | * any part of it back; both leave a row saying who asked and why. | |
| 4 | * Everything here is for an admin — the role the forge signs into | |
| 5 | * `ctx.who` — and every write is a POST with the session's CSRF token. An | |
| 6 | * admin can see any order and give any part of it back; both leave a row | |
| 7 | * saying who asked and why. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import * as catalog from "./catalog.ts"; |
| @@ -12,16 +13,15 @@ import * as giftcards from "./giftcards.ts"; | ||
| 12 | 13 | import * as tax from "./tax.ts"; |
| 13 | 14 | import * as gateway from "./gateway.ts"; |
| 14 | 15 | import * as inventory from "./inventory.ts"; |
| 15 | import * as limit from "./ratelimit.ts"; | |
| 16 | 16 | import * as orders from "./orders.ts"; |
| 17 | 17 | import * as pages from "./pages/admin.ts"; |
| 18 | 18 | import { db, type Variant } from "./db.ts"; |
| 19 | 19 | import { field, intField, redirect } from "./http.ts"; |
| 20 | 20 | import { forOrder, outbox } from "./mail.ts"; |
| 21 | 21 | import { parseCents } from "./money.ts"; |
| 22 | import { signIn, signOut, usingGeneratedPassword, verifyAdminPassword } from "./session.ts"; | |
| 23 | 22 | import { render, type Ctx } from "./context.ts"; |
| 24 | import { notFound, tooMany } from "./shop.ts"; | |
| 23 | import { notFound } from "./shop.ts"; | |
| 24 | import { identityConfigured } from "./who.ts"; | |
| 25 | 25 | |
| 26 | 26 | const MESSAGES: Record<string, { text: string; bad?: boolean }> = { |
| 27 | 27 | saved: { text: "Saved." }, |
| @@ -44,8 +44,19 @@ const MESSAGES: Record<string, { text: string; bad?: boolean }> = { | ||
| 44 | 44 | |
| 45 | 45 | const messageFrom = (ctx: Ctx) => MESSAGES[(ctx.url.searchParams.get("m") ?? "").slice(0, 30)]; |
| 46 | 46 | |
| 47 | /** | |
| 48 | * Every path under /admin passes through here. Only an admin the forge signed | |
| 49 | * gets in; anyone else gets one page whose only action is to sign in. With no | |
| 50 | * FORGE_APP_IDENTITY_KEY nothing can be verified, so nobody is an admin. | |
| 51 | */ | |
| 47 | 52 | const guard = (ctx: Ctx): Response | null => |
| 48 | ctx.session.isAdmin ? null : render(ctx, { title: "Admin", admin: true, status: 401 }, pages.signIn(ctx.session, usingGeneratedPassword())); | |
| 53 | ctx.who.role === "admin" | |
| 54 | ? null | |
| 55 | : render( | |
| 56 | ctx, | |
| 57 | { title: "Admin", admin: true, status: 403 }, | |
| 58 | pages.forbidden(ctx.url.pathname + ctx.url.search, identityConfigured()), | |
| 59 | ); | |
| 49 | 60 | |
| 50 | 61 | // --- the door --------------------------------------------------------------- |
| 51 | 62 | |
| @@ -73,24 +84,6 @@ export function dashboard(ctx: Ctx): Response { | ||
| 73 | 84 | return render(ctx, { title: "Admin", admin: true }, pages.overview({ ...numbers, ...counts }, orders.recent(10))); |
| 74 | 85 | } |
| 75 | 86 | |
| 76 | export function signInPost(ctx: Ctx): Response { | |
| 77 | if (!limit.take(limit.SIGN_IN, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.SIGN_IN, ctx.ip), "sign-in"); | |
| 78 | const password = String(ctx.form?.get("password") ?? ""); | |
| 79 | if (!verifyAdminPassword(password)) | |
| 80 | return render( | |
| 81 | ctx, | |
| 82 | { title: "Admin", admin: true, status: 401 }, | |
| 83 | pages.signIn(ctx.session, usingGeneratedPassword(), "That password is not the one."), | |
| 84 | ); | |
| 85 | signIn(ctx.session.sid); | |
| 86 | return redirect("/admin"); | |
| 87 | } | |
| 88 | ||
| 89 | export function signOutPost(ctx: Ctx): Response { | |
| 90 | signOut(ctx.session.sid); | |
| 91 | return redirect("/"); | |
| 92 | } | |
| 93 | ||
| 94 | 87 | // --- products --------------------------------------------------------------- |
| 95 | 88 | |
| 96 | 89 | export function productList(ctx: Ctx): Response { |
src/context.ts +6 −1
| @@ -10,12 +10,16 @@ import { layout } from "./html.ts"; | ||
| 10 | 10 | import type { Applied } from "./codes.ts"; |
| 11 | 11 | import type { CartLine } from "./cart.ts"; |
| 12 | 12 | import type { Session } from "./session.ts"; |
| 13 | import type { Who } from "./who.ts"; | |
| 13 | 14 | |
| 14 | 15 | export type Ctx = { |
| 15 | 16 | request: Request; |
| 16 | 17 | url: URL; |
| 17 | 18 | params: Record<string, string>; |
| 19 | /** The CSRF token's session. It says nothing about who is asking. */ | |
| 18 | 20 | session: Session; |
| 21 | /** Who the forge says is asking, verified in src/who.ts. */ | |
| 22 | who: Who; | |
| 19 | 23 | /** Parsed for POST routes; null for GET and for routes that read the body themselves. */ |
| 20 | 24 | form: FormData | null; |
| 21 | 25 | cart: CartLine[]; |
| @@ -40,7 +44,8 @@ export function render( | ||
| 40 | 44 | return html( |
| 41 | 45 | layout({ |
| 42 | 46 | title: options.title, |
| 43 | session: ctx.session, | |
| 47 | who: ctx.who, | |
| 48 | path: ctx.url.pathname, | |
| 44 | 49 | cartCount: cartCount(ctx.cart), |
| 45 | 50 | categories: categories(), |
| 46 | 51 | current: options.current, |
src/db.ts +10
| @@ -350,6 +350,16 @@ export type Payment = { | ||
| 350 | 350 | created_at: string; |
| 351 | 351 | }; |
| 352 | 352 | |
| 353 | /** | |
| 354 | * The settings the admin password left behind. Identity comes from the forge | |
| 355 | * now, so the generated password and its salt open nothing — and a secret | |
| 356 | * with no purpose should not sit in the database. The schema itself is left | |
| 357 | * alone: this file grows tables, it never rebuilds them. | |
| 358 | */ | |
| 359 | export const forgetPasswordSettings = (): void => { | |
| 360 | db.query("DELETE FROM setting WHERE key IN ('admin_demo_password', 'admin_salt')").run(); | |
| 361 | }; | |
| 362 | ||
| 353 | 363 | export const getSetting = (key: string): string | undefined => |
| 354 | 364 | db.query<{ value: string }, [string]>("SELECT value FROM setting WHERE key = ?1").get(key)?.value; |
| 355 | 365 |
src/html.ts +16 −2
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { CSS_PATH } from "./style.ts"; |
| 11 | import { SIGN_OUT_HREF, signInHref } from "./who.ts"; | |
| 11 | 12 | import type { Session } from "./session.ts"; |
| 13 | import type { Who } from "./who.ts"; | |
| 12 | 14 | |
| 13 | 15 | export const escape = (value: unknown): string => |
| 14 | 16 | String(value ?? "").replace( |
| @@ -39,7 +41,10 @@ export function art(seed: string, label: string, className = "art"): string { | ||
| 39 | 41 | |
| 40 | 42 | export type Chrome = { |
| 41 | 43 | title: string; |
| 42 | session: Session; | |
| 44 | /** Who the forge says is here: it decides the account corner and the Admin link. */ | |
| 45 | who: Who; | |
| 46 | /** The path being rendered, so "Sign in" comes back to it. */ | |
| 47 | path: string; | |
| 43 | 48 | cartCount: number; |
| 44 | 49 | categories: { slug: string; name: string }[]; |
| 45 | 50 | current?: string; |
| @@ -48,6 +53,14 @@ export type Chrome = { | ||
| 48 | 53 | body: string; |
| 49 | 54 | }; |
| 50 | 55 | |
| 56 | /** The account corner: who the forge says you are, and the door either way. */ | |
| 57 | export const account = (who: Who, path: string): string => | |
| 58 | who.role === "anon" | |
| 59 | ? `<span class="who"><a href="${escape(signInHref(path))}">Sign in</a></span>` | |
| 60 | : `<span class="who"><strong>${escape(who.name)}</strong>${ | |
| 61 | who.role === "admin" ? ' <span class="muted">admin</span>' : "" | |
| 62 | } <a href="${escape(SIGN_OUT_HREF)}">Sign out</a></span>`; | |
| 63 | ||
| 51 | 64 | export function layout(chrome: Chrome): string { |
| 52 | 65 | const link = (href: string, text: string, key: string): string => |
| 53 | 66 | `<a href="${escape(href)}"${chrome.current === key ? ' aria-current="page"' : ""}>${escape(text)}</a>`; |
| @@ -64,13 +77,14 @@ export function layout(chrome: Chrome): string { | ||
| 64 | 77 | <a class="brand" href="/">bazaar</a> |
| 65 | 78 | <nav class="cats"> |
| 66 | 79 | ${chrome.categories.map((category) => link(`/c/${category.slug}`, category.name, `c:${category.slug}`)).join("\n ")} |
| 67 | ${chrome.session.isAdmin ? link("/admin", "Admin", "admin") : ""} | |
| 80 | ${chrome.who.role === "admin" ? link("/admin", "Admin", "admin") : ""} | |
| 68 | 81 | </nav> |
| 69 | 82 | <form class="find" method="get" action="/search" role="search"> |
| 70 | 83 | <input type="search" name="q" placeholder="Search" value="${escape(chrome.query ?? "")}" aria-label="Search products" maxlength="80"> |
| 71 | 84 | <button class="btn ghost">Find</button> |
| 72 | 85 | </form> |
| 73 | 86 | <a class="cart-link" href="/cart">Cart${chrome.cartCount > 0 ? ` · ${chrome.cartCount}` : ""}</a> |
| 87 | ${account(chrome.who, chrome.path)} | |
| 74 | 88 | </div></header> |
| 75 | 89 | <main><div class="wrap">${chrome.body}</div></main> |
| 76 | 90 | <footer class="bottom"><div class="wrap"> |
src/pages/admin.ts +14 −17
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { csrfField, escape, variantRowStyle } from "../html.ts"; |
| 8 | import { SIGN_OUT_HREF, signInHref } from "../who.ts"; | |
| 8 | 9 | import { formatCents } from "../money.ts"; |
| 9 | 10 | import { itemsTable, orderTotals, statusPill } from "./checkout.ts"; |
| 10 | 11 | import { describe } from "../discounts.ts"; |
| @@ -30,26 +31,22 @@ export const nav = (current: string): string => | ||
| 30 | 31 | key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`, |
| 31 | 32 | ) |
| 32 | 33 | .join("")} |
| 33 | <form method="post" action="/admin/signout" class="inline" style="margin-left:auto"> | |
| 34 | <button class="btn ghost" formnovalidate>Sign out</button> | |
| 35 | </form></nav>`; | |
| 34 | <a href="${escape(SIGN_OUT_HREF)}" style="margin-left:auto">Sign out</a></nav>`; | |
| 36 | 35 | |
| 37 | export const signIn = (session: Session, generated: boolean, error?: string): string => ` | |
| 36 | /** | |
| 37 | * What anyone who is not an admin sees under /admin: one sentence and one | |
| 38 | * action. There is no password here to get wrong — the forge says who you | |
| 39 | * are, so the only way in is through the forge's sign-in and back. | |
| 40 | */ | |
| 41 | export const forbidden = (to: string, configured: boolean): string => ` | |
| 38 | 42 | <h1>Admin</h1> |
| 39 | ${error ? `<p class="notice bad">${escape(error)}</p>` : ""} | |
| 40 | <form method="post" action="/admin/signin" class="stack"> | |
| 41 | ${csrfField(session)} | |
| 42 | <label>Password | |
| 43 | <input type="password" name="password" autocomplete="current-password" required maxlength="200" autofocus> | |
| 44 | </label> | |
| 45 | <div><button class="btn">Sign in</button></div> | |
| 46 | </form> | |
| 43 | <p class="lede">This is for the people who run the shop. The forge says who you are; it has not said you are an admin here.</p> | |
| 44 | <p><a class="btn" href="${escape(signInHref(to))}">Sign in</a></p> | |
| 47 | 45 | ${ |
| 48 | generated | |
| 49 | ? `<p class="notice">No <code>ADMIN_PASSWORD</code> is set, so this environment generated one. It is printed in the | |
| 50 | app's run log when the app starts, and shown in the app's terminal — never on this page. Set | |
| 51 | <code>ADMIN_PASSWORD</code> to choose it yourself.</p>` | |
| 52 | : '<p class="muted">The password is <code>ADMIN_PASSWORD</code> from the environment.</p>' | |
| 46 | configured | |
| 47 | ? '<p class="muted">Signed in already? Then this account is not an admin of this app.</p>' | |
| 48 | : `<p class="notice">This deployment has no <code>FORGE_APP_IDENTITY_KEY</code>, so no identity can be | |
| 49 | verified and /admin is closed to everybody. That is the only setting there is: there is no password.</p>` | |
| 53 | 50 | } |
| 54 | 51 | `; |
| 55 | 52 |
src/pages/checkout.ts +3 −2
| @@ -13,6 +13,7 @@ import type { Order, OrderItem, Payment } from "../db.ts"; | ||
| 13 | 13 | import type { GatewayInfo } from "../gateway.ts"; |
| 14 | 14 | import type { Email } from "../mail.ts"; |
| 15 | 15 | import type { Session } from "../session.ts"; |
| 16 | import type { Who } from "../who.ts"; | |
| 16 | 17 | |
| 17 | 18 | const field = ( |
| 18 | 19 | name: string, |
| @@ -199,7 +200,7 @@ export function confirmation( | ||
| 199 | 200 | items: OrderItem[], |
| 200 | 201 | refunded: number, |
| 201 | 202 | email: Email | null, |
| 202 | session: Session, | |
| 203 | who: Who, | |
| 203 | 204 | ): string { |
| 204 | 205 | const paid = order.status === "paid" || order.status === "refunded" || order.status === "partially_refunded"; |
| 205 | 206 | return ` |
| @@ -223,7 +224,7 @@ ${orderTotals(order, refunded)} | ||
| 223 | 224 | <h2>Shipping to</h2> |
| 224 | 225 | <p>${addressBlock(order)}<br><span class="muted">${escape(order.email)}</span></p> |
| 225 | 226 | <p class="muted">Keep this address: it is the only way back to this order. |
| 226 | ${session.isAdmin ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p> | |
| 227 | ${who.role === "admin" ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p> | |
| 227 | 228 | `; |
| 228 | 229 | } |
| 229 | 230 |
src/ratelimit.ts −1
| @@ -12,7 +12,6 @@ export type Limit = { name: string; capacity: number; perSeconds: number }; | ||
| 12 | 12 | |
| 13 | 13 | export const CHECKOUT: Limit = { name: "checkout", capacity: 8, perSeconds: 60 }; |
| 14 | 14 | export const PAYMENT: Limit = { name: "payment", capacity: 20, perSeconds: 60 }; |
| 15 | export const SIGN_IN: Limit = { name: "signin", capacity: 5, perSeconds: 300 }; | |
| 16 | 15 | // Codes are guessable in principle, so trying them is the slowest thing here. |
| 17 | 16 | export const CODES: Limit = { name: "codes", capacity: 10, perSeconds: 120 }; |
| 18 | 17 |
src/routes.ts +2 −2
| @@ -40,9 +40,9 @@ export const routes: Route[] = [ | ||
| 40 | 40 | { method: "GET", pattern: "/orders/:number", handler: shop.orderPage }, |
| 41 | 41 | { method: "GET", pattern: "/orders/:number/receipt", handler: shop.receiptPage }, |
| 42 | 42 | |
| 43 | // Everything under /admin is for an admin the forge signed; the guard in | |
| 44 | // src/admin.ts is on each handler, not on this table. | |
| 43 | 45 | { method: "GET", pattern: "/admin", handler: admin.dashboard }, |
| 44 | { method: "POST", pattern: "/admin/signin", handler: admin.signInPost }, | |
| 45 | { method: "POST", pattern: "/admin/signout", handler: admin.signOutPost }, | |
| 46 | 46 | { method: "GET", pattern: "/admin/products", handler: admin.productList }, |
| 47 | 47 | { method: "POST", pattern: "/admin/products", handler: admin.productCreate }, |
| 48 | 48 | { method: "GET", pattern: "/admin/products/new", handler: admin.productNew }, |
src/server.test.ts +98 −39
| @@ -4,20 +4,37 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | |
| 6 | 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-")); |
| 7 | process.env.ADMIN_PASSWORD = "open-sesame-for-the-test"; | |
| 7 | process.env.FORGE_APP_IDENTITY_KEY = "the-key-the-forge-signs-with"; | |
| 8 | 8 | process.env.PORT = "0"; |
| 9 | 9 | delete process.env.STRIPE_SECRET_KEY; |
| 10 | 10 | |
| 11 | const { stamp } = await import("./who.ts"); | |
| 11 | 12 | const { server } = await import("./server.ts"); |
| 12 | 13 | const { db } = await import("./db.ts"); |
| 13 | 14 | const limit = await import("./ratelimit.ts"); |
| 14 | 15 | const giftcards = await import("./giftcards.ts"); |
| 15 | 16 | const base = `http://${server.hostname}:${server.port}`; |
| 16 | 17 | |
| 18 | type Person = { id: string; name: string; role: "user" | "admin"; expiresMs?: number }; | |
| 19 | ||
| 20 | /** Sign an identity the way the forge does, so a test can be somebody. */ | |
| 21 | function identityHeaders(person: Person | undefined, key = process.env.FORGE_APP_IDENTITY_KEY!): Record<string, string> { | |
| 22 | if (!person) return {}; | |
| 23 | const identity = `${person.id}|${person.name}|${person.role}|${person.expiresMs ?? Date.now() + 600_000}`; | |
| 24 | return { "x-forge-identity": identity, "x-forge-signature": stamp(key, identity) }; | |
| 25 | } | |
| 26 | ||
| 17 | 27 | /** A browser: it keeps its cookies and posts like a same-origin form does. */ |
| 18 | 28 | class Shopper { |
| 19 | 29 | jar = new Map<string, string>(); |
| 20 | 30 | |
| 31 | /** Who the forge says this browser is; undefined is a signed-out visitor. */ | |
| 32 | constructor(private person?: Person) {} | |
| 33 | ||
| 34 | private forge(): Record<string, string> { | |
| 35 | return identityHeaders(this.person); | |
| 36 | } | |
| 37 | ||
| 21 | 38 | private remember(response: Response): void { |
| 22 | 39 | for (const line of response.headers.getSetCookie()) { |
| 23 | 40 | const [pair] = line.split(";"); |
| @@ -32,7 +49,7 @@ class Shopper { | ||
| 32 | 49 | |
| 33 | 50 | async get(path: string): Promise<Response> { |
| 34 | 51 | const response = await fetch(base + path, { |
| 35 | headers: { cookie: this.cookieHeader(), "sec-fetch-site": "same-origin" }, | |
| 52 | headers: { cookie: this.cookieHeader(), "sec-fetch-site": "same-origin", ...this.forge() }, | |
| 36 | 53 | redirect: "manual", |
| 37 | 54 | }); |
| 38 | 55 | this.remember(response); |
| @@ -53,6 +70,7 @@ class Shopper { | ||
| 53 | 70 | cookie: this.cookieHeader(), |
| 54 | 71 | "sec-fetch-site": "same-origin", |
| 55 | 72 | "content-type": "application/x-www-form-urlencoded", |
| 73 | ...this.forge(), | |
| 56 | 74 | }, |
| 57 | 75 | redirect: "manual", |
| 58 | 76 | }); |
| @@ -66,6 +84,9 @@ class Shopper { | ||
| 66 | 84 | } |
| 67 | 85 | } |
| 68 | 86 | |
| 87 | /** An admin, the way the forge signs one in. */ | |
| 88 | const asAdmin = (): Shopper => new Shopper({ id: "u_1", name: "ada", role: "admin" }); | |
| 89 | ||
| 69 | 90 | const variantId = (sku: string): number => |
| 70 | 91 | db.query<{ id: number }, [string]>("SELECT id FROM variant WHERE sku = ?1").get(sku)!.id; |
| 71 | 92 | const stockOf = (sku: string): number => |
| @@ -548,24 +569,19 @@ test("an unsigned webhook is refused", async () => { | ||
| 548 | 569 | expect(response.status).toBe(400); |
| 549 | 570 | }); |
| 550 | 571 | |
| 551 | test("admin is closed until it is signed into, then refunds and restocks", async () => { | |
| 572 | test("/admin opens for an admin the forge signed, and refunds and restocks", async () => { | |
| 552 | 573 | limit.forget(); |
| 553 | 574 | const shopper = new Shopper(); |
| 554 | 575 | const { number } = await buy(shopper, "COF-GUJI-1000", 1); |
| 555 | 576 | |
| 556 | const admin = new Shopper(); | |
| 557 | const closed = await admin.get("/admin/orders"); | |
| 558 | expect(closed.status).toBe(401); | |
| 559 | expect(await closed.text()).toContain("Password"); | |
| 560 | ||
| 561 | const door = await admin.page("/admin"); | |
| 562 | const wrong = await admin.post("/admin/signin", { password: "not-it" }, Shopper.tokenIn(door)); | |
| 563 | expect(wrong.status).toBe(401); | |
| 564 | ||
| 565 | const signedIn = await admin.post("/admin/signin", { password: process.env.ADMIN_PASSWORD! }, Shopper.tokenIn(door)); | |
| 566 | expect(signedIn.status).toBe(303); | |
| 577 | const admin = asAdmin(); | |
| 567 | 578 | expect(await admin.page("/admin")).toContain("Overview"); |
| 568 | 579 | expect(await admin.page("/admin/orders")).toContain(number); |
| 580 | // the header names them, and only an admin is offered the Admin link | |
| 581 | const front = await admin.page("/"); | |
| 582 | expect(front).toContain("ada"); | |
| 583 | expect(front).toContain("/__forge/signout"); | |
| 584 | expect(front).toContain('href="/admin"'); | |
| 569 | 585 | |
| 570 | 586 | const stockBefore = stockOf("COF-GUJI-1000"); |
| 571 | 587 | const orderPage = await admin.page(`/admin/orders/${number}`); |
| @@ -595,34 +611,79 @@ test("admin is closed until it is signed into, then refunds and restocks", async | ||
| 595 | 611 | expect(await admin.page("/admin/mail")).toContain(`Your bazaar order ${number}`); |
| 596 | 612 | }); |
| 597 | 613 | |
| 598 | test("a generated admin password is never printed on the sign-in page", async () => { | |
| 599 | limit.forget(); | |
| 600 | const configured = process.env.ADMIN_PASSWORD; | |
| 601 | delete process.env.ADMIN_PASSWORD; | |
| 602 | const { ensureDemoPassword } = await import("./session.ts"); | |
| 603 | const generated = ensureDemoPassword()!; | |
| 604 | expect(generated.length).toBeGreaterThan(8); | |
| 614 | test("/admin is 403 and a sign-in link for everybody the forge did not sign as an admin", async () => { | |
| 615 | const anon = new Shopper(); | |
| 616 | const closed = await anon.get("/admin/orders"); | |
| 617 | expect(closed.status).toBe(403); | |
| 618 | const page = await closed.text(); | |
| 619 | expect(page).toContain("/__forge/signin?to=%2Fadmin%2Forders"); | |
| 620 | expect(page).toContain("Sign in"); | |
| 621 | expect(page).not.toContain('type="password"'); | |
| 622 | // the shop itself is still open, and the header offers the way in | |
| 623 | expect(await anon.page("/")).toContain("/__forge/signin?to=%2F"); | |
| 605 | 624 | |
| 606 | const visitor = new Shopper(); | |
| 607 | const door = await visitor.page("/admin"); | |
| 608 | expect(door).not.toContain(generated); | |
| 609 | expect(door).toContain("run log"); | |
| 610 | const wrong = await visitor.post("/admin/signin", { password: "guess" }, Shopper.tokenIn(door)); | |
| 611 | expect(wrong.status).toBe(401); | |
| 612 | expect(await wrong.text()).not.toContain(generated); | |
| 625 | const user = new Shopper({ id: "u_2", name: "bo", role: "user" }); | |
| 626 | expect((await user.get("/admin")).status).toBe(403); | |
| 627 | expect((await user.get("/admin/products")).status).toBe(403); | |
| 628 | expect(await user.page("/")).not.toContain('href="/admin"'); | |
| 629 | // a write is refused by the guard, not merely by the CSRF check: this post | |
| 630 | // carries a good token from a page the user is allowed to see | |
| 631 | const before = stockOf("BRW-FILT-100"); | |
| 632 | const csrf = Shopper.tokenIn(await user.page(`/p/${slugOf("BRW-FILT-100")}`)); | |
| 633 | expect(csrf).not.toBe(""); | |
| 634 | const wrote = await user.post( | |
| 635 | "/admin/inventory", | |
| 636 | { variant: String(variantId("BRW-FILT-100")), delta: "+5", reason: "mine now" }, | |
| 637 | csrf, | |
| 638 | ); | |
| 639 | expect(wrote.status).toBe(403); | |
| 640 | expect(stockOf("BRW-FILT-100")).toBe(before); | |
| 613 | 641 | |
| 614 | // it is still the password, for whoever read it from the log or the terminal | |
| 615 | const signedIn = await visitor.post("/admin/signin", { password: generated }, Shopper.tokenIn(door)); | |
| 616 | expect(signedIn.status).toBe(303); | |
| 642 | // an admin's own stamp, with a character moved | |
| 643 | const stale = new Shopper({ id: "u_1", name: "ada", role: "admin", expiresMs: Date.now() - 1000 }); | |
| 644 | expect((await stale.get("/admin")).status).toBe(403); | |
| 617 | 645 | |
| 618 | process.env.ADMIN_PASSWORD = configured; | |
| 619 | limit.forget(); | |
| 646 | const identity = `u_1|ada|admin|${Date.now() + 600_000}`; | |
| 647 | const tampered = await fetch(base + "/admin", { | |
| 648 | headers: { | |
| 649 | "sec-fetch-site": "same-origin", | |
| 650 | "x-forge-identity": identity, | |
| 651 | "x-forge-signature": stamp("a-key-that-is-not-the-forge", identity), | |
| 652 | }, | |
| 653 | redirect: "manual", | |
| 654 | }); | |
| 655 | expect(tampered.status).toBe(403); | |
| 656 | ||
| 657 | // and a claim with no signature at all | |
| 658 | const bare = await fetch(base + "/admin", { | |
| 659 | headers: { "sec-fetch-site": "same-origin", "x-forge-identity": identity }, | |
| 660 | redirect: "manual", | |
| 661 | }); | |
| 662 | expect(bare.status).toBe(403); | |
| 663 | }); | |
| 664 | ||
| 665 | test("with no FORGE_APP_IDENTITY_KEY /admin is closed to everybody, and says so", async () => { | |
| 666 | const key = process.env.FORGE_APP_IDENTITY_KEY!; | |
| 667 | const identity = `u_1|ada|admin|${Date.now() + 600_000}`; | |
| 668 | delete process.env.FORGE_APP_IDENTITY_KEY; | |
| 669 | try { | |
| 670 | const shut = await fetch(base + "/admin", { | |
| 671 | headers: { | |
| 672 | "sec-fetch-site": "same-origin", | |
| 673 | "x-forge-identity": identity, | |
| 674 | "x-forge-signature": stamp(key, identity), | |
| 675 | }, | |
| 676 | redirect: "manual", | |
| 677 | }); | |
| 678 | expect(shut.status).toBe(403); | |
| 679 | expect(await shut.text()).toContain("FORGE_APP_IDENTITY_KEY"); | |
| 680 | } finally { | |
| 681 | process.env.FORGE_APP_IDENTITY_KEY = key; | |
| 682 | } | |
| 620 | 683 | }); |
| 621 | 684 | |
| 622 | 685 | test("an admin moves stock and the ledger says why", async () => { |
| 623 | const admin = new Shopper(); | |
| 624 | const door = await admin.page("/admin"); | |
| 625 | await admin.post("/admin/signin", { password: process.env.ADMIN_PASSWORD! }, Shopper.tokenIn(door)); | |
| 686 | const admin = asAdmin(); | |
| 626 | 687 | const page = await admin.page("/admin/inventory"); |
| 627 | 688 | const before = stockOf("BRW-FILT-100"); |
| 628 | 689 | const moved = await admin.post( |
| @@ -636,9 +697,7 @@ test("an admin moves stock and the ledger says why", async () => { | ||
| 636 | 697 | }); |
| 637 | 698 | |
| 638 | 699 | test("an admin adds a product and a variant, and the shop sells it", async () => { |
| 639 | const admin = new Shopper(); | |
| 640 | const door = await admin.page("/admin"); | |
| 641 | await admin.post("/admin/signin", { password: process.env.ADMIN_PASSWORD! }, Shopper.tokenIn(door)); | |
| 700 | const admin = asAdmin(); | |
| 642 | 701 | const form = await admin.page("/admin/products/new"); |
| 643 | 702 | const categoryId = db.query<{ id: number }, []>("SELECT id FROM category ORDER BY id LIMIT 1").get()!.id; |
| 644 | 703 | const created = await admin.post( |
src/server.ts +20 −17
| @@ -6,11 +6,11 @@ | ||
| 6 | 6 | * |
| 7 | 7 | * This file is the front door and nothing else: it puts the security floor on |
| 8 | 8 | * every response, refuses a POST that is not same-origin and does not carry |
| 9 | * the session's CSRF token, builds the request's context, and hands over to | |
| 10 | * the table in src/routes.ts. | |
| 9 | * the session's CSRF token, works out who the forge says is asking, builds | |
| 10 | * the request's context, and hands over to the table in src/routes.ts. | |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { dataDir, db } from "./db.ts"; | |
| 13 | import { dataDir, db, forgetPasswordSettings } from "./db.ts"; | |
| 14 | 14 | import { decode as decodeCart, COOKIE as CART_COOKIE } from "./cart.ts"; |
| 15 | 15 | import { decode as decodeCodes, COOKIE as CODES_COOKIE } from "./codes.ts"; |
| 16 | 16 | import { CSS, CSS_PATH } from "./style.ts"; |
| @@ -18,7 +18,6 @@ import { | ||
| 18 | 18 | checkCsrf, |
| 19 | 19 | cookie, |
| 20 | 20 | COOKIE as SESSION_COOKIE, |
| 21 | ensureDemoPassword, | |
| 22 | 21 | load as loadSession, |
| 23 | 22 | readCookies, |
| 24 | 23 | useSecret, |
| @@ -30,6 +29,8 @@ import { seed } from "./catalog.ts"; | ||
| 30 | 29 | import { seed as seedDiscounts } from "./discounts.ts"; |
| 31 | 30 | import { seed as seedTax } from "./tax.ts"; |
| 32 | 31 | import { text } from "./http.ts"; |
| 32 | import { whoIs } from "./who.ts"; | |
| 33 | import type { Who } from "./who.ts"; | |
| 33 | 34 | import type { Ctx } from "./context.ts"; |
| 34 | 35 | |
| 35 | 36 | const secret = loadSecret(dataDir); |
| @@ -103,11 +104,14 @@ export const server = Bun.serve({ | ||
| 103 | 104 | |
| 104 | 105 | const found = match(method, url.pathname); |
| 105 | 106 | const session = loadSession(cookies[SESSION_COOKIE]); |
| 107 | // Identity is the forge's to assert and ours to verify; the cookie above | |
| 108 | // only carries the CSRF token. | |
| 109 | const who = whoIs(request.headers); | |
| 106 | 110 | if (cookies[SESSION_COOKIE] !== session.sid) |
| 107 | 111 | outgoing.push(cookie(SESSION_COOKIE, session.sid, { maxAge: 60 * 60 * 24 * 30, secure: publicOrigin.startsWith("https:") })); |
| 108 | 112 | |
| 109 | 113 | if (!found) { |
| 110 | const ctx = context(request, url, {}, session, null, cookies, outgoing, self); | |
| 114 | const ctx = context(request, url, {}, session, who, null, cookies, outgoing, self); | |
| 111 | 115 | return finish(notFound(ctx), outgoing); |
| 112 | 116 | } |
| 113 | 117 | |
| @@ -125,7 +129,7 @@ export const server = Bun.serve({ | ||
| 125 | 129 | return finish(text("that form was not from here — go back and try again", 403), outgoing); |
| 126 | 130 | } |
| 127 | 131 | |
| 128 | const ctx = context(request, url, found.params, session, form, cookies, outgoing, self); | |
| 132 | const ctx = context(request, url, found.params, session, who, form, cookies, outgoing, self); | |
| 129 | 133 | try { |
| 130 | 134 | const response = await found.route.handler(ctx); |
| 131 | 135 | return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing); |
| @@ -141,6 +145,7 @@ function context( | ||
| 141 | 145 | url: URL, |
| 142 | 146 | params: Record<string, string>, |
| 143 | 147 | session: ReturnType<typeof loadSession>, |
| 148 | who: Who, | |
| 144 | 149 | form: FormData | null, |
| 145 | 150 | cookies: Record<string, string>, |
| 146 | 151 | outgoing: string[], |
| @@ -152,6 +157,7 @@ function context( | ||
| 152 | 157 | url, |
| 153 | 158 | params, |
| 154 | 159 | session, |
| 160 | who, | |
| 155 | 161 | form, |
| 156 | 162 | cart: decodeCart(cookies[CART_COOKIE], secret), |
| 157 | 163 | codes: decodeCodes(cookies[CODES_COOKIE], secret), |
| @@ -163,18 +169,15 @@ function context( | ||
| 163 | 169 | }; |
| 164 | 170 | } |
| 165 | 171 | |
| 166 | // Old carts and abandoned sessions are not worth keeping; this is cheap and | |
| 167 | // happens once at boot, after the socket is already listening. | |
| 172 | // Old carts and abandoned sessions are not worth keeping, and the password | |
| 173 | // this app used to have left two settings that now open nothing. Both are | |
| 174 | // cheap and happen once at boot, after the socket is already listening. | |
| 168 | 175 | db.query("DELETE FROM session WHERE seen_at < datetime('now', '-30 days')").run(); |
| 176 | forgetPasswordSettings(); | |
| 169 | 177 | |
| 170 | 178 | console.log(`bazaar listening on http://${server.hostname}:${server.port} (${env})`); |
| 171 | 179 | |
| 172 | // When nobody set ADMIN_PASSWORD the app makes one. It is told here, to | |
| 173 | // whoever can read the run log, and in the app's terminal — never to a | |
| 174 | // browser, because a sign-in page that prints the password is not a door. | |
| 175 | const generated = ensureDemoPassword(); | |
| 176 | if (generated) | |
| 177 | console.log( | |
| 178 | `admin password (generated, kept in ${dataDir}/app.db): ${generated}\n` + | |
| 179 | " set ADMIN_PASSWORD to choose your own; it is never shown on a page.", | |
| 180 | ); | |
| 180 | // There is no password to announce: /admin opens for whoever the forge signs | |
| 181 | // in as an admin. Without the key nothing can be verified, so say so once. | |
| 182 | if (!process.env.FORGE_APP_IDENTITY_KEY) | |
| 183 | console.log("FORGE_APP_IDENTITY_KEY is unset: every request is anonymous and /admin is closed to everybody."); |
src/session.ts +11 −55
| @@ -1,20 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Sessions and CSRF. |
| 3 | 3 | * |
| 4 | * A session is a random id in an HttpOnly cookie and a row in the database; | |
| 5 | * the cookie carries nothing else, so signing out is a delete the browser | |
| 6 | * cannot argue with. The CSRF token for a session is an HMAC of its id: it | |
| 7 | * needs no storage, it cannot be guessed without the secret, and a form | |
| 8 | * posted from another site cannot carry one because it cannot read the page. | |
| 4 | * A session is a random id in an HttpOnly cookie and a row in the database. | |
| 5 | * It says nothing about who anybody is — that comes from the forge, in | |
| 6 | * src/who.ts — it exists so a form can carry a CSRF token. The token is an | |
| 7 | * HMAC of the session id: it needs no storage, it cannot be guessed without | |
| 8 | * the secret, and a form posted from another site cannot carry one because it | |
| 9 | * cannot read the page. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | import { randomBytes, scryptSync } from "node:crypto"; | |
| 12 | import { db, getSetting, setSetting } from "./db.ts"; | |
| 12 | import { db } from "./db.ts"; | |
| 13 | 13 | import { equals, hmac, token } from "./sign.ts"; |
| 14 | 14 | |
| 15 | 15 | export const COOKIE = "sid"; |
| 16 | 16 | |
| 17 | export type Session = { id: number; sid: string; isAdmin: boolean; csrf: string }; | |
| 17 | export type Session = { id: number; sid: string; csrf: string }; | |
| 18 | 18 | |
| 19 | 19 | const secretRef = { value: "" }; |
| 20 | 20 | export const useSecret = (secret: string): void => { |
| @@ -44,60 +44,16 @@ export function cookie(name: string, value: string, options: { maxAge?: number; | ||
| 44 | 44 | /** The session the cookie names, or a fresh one. Never trusts the cookie's claims. */ |
| 45 | 45 | export function load(sid: string | undefined): Session { |
| 46 | 46 | if (sid && /^[A-Za-z0-9_-]{16,64}$/.test(sid)) { |
| 47 | const row = db.query<{ id: number; is_admin: number }, [string]>("SELECT id, is_admin FROM session WHERE id = ?1").get(sid); | |
| 47 | const row = db.query<{ id: number }, [string]>("SELECT id FROM session WHERE id = ?1").get(sid); | |
| 48 | 48 | if (row) { |
| 49 | 49 | db.query("UPDATE session SET seen_at = datetime('now') WHERE id = ?1").run(sid); |
| 50 | return { id: row.id, sid, isAdmin: row.is_admin === 1, csrf: csrfFor(sid) }; | |
| 50 | return { id: row.id, sid, csrf: csrfFor(sid) }; | |
| 51 | 51 | } |
| 52 | 52 | } |
| 53 | 53 | const fresh = token(24); |
| 54 | 54 | const row = db.query<{ id: number }, [string]>("INSERT INTO session (id) VALUES (?1) RETURNING id").get(fresh)!; |
| 55 | return { id: row.id, sid: fresh, isAdmin: false, csrf: csrfFor(fresh) }; | |
| 55 | return { id: row.id, sid: fresh, csrf: csrfFor(fresh) }; | |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | export const signIn = (sid: string): void => { | |
| 59 | db.query("UPDATE session SET is_admin = 1 WHERE id = ?1").run(sid); | |
| 60 | }; | |
| 61 | ||
| 62 | export const signOut = (sid: string): void => { | |
| 63 | db.query("DELETE FROM session WHERE id = ?1").run(sid); | |
| 64 | }; | |
| 65 | ||
| 66 | 58 | export const checkCsrf = (session: Session, given: unknown): boolean => |
| 67 | 59 | typeof given === "string" && given.length > 0 && equals(given, session.csrf); |
| 68 | ||
| 69 | // --- the admin password ----------------------------------------------------- | |
| 70 | ||
| 71 | const derive = (password: string, salt: string): string => | |
| 72 | scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString("base64url"); | |
| 73 | ||
| 74 | /** | |
| 75 | * ADMIN_PASSWORD from the environment when it is set. When it is not — a | |
| 76 | * preview nobody handed a secret to — one random password is generated and | |
| 77 | * kept. It is told to whoever runs the app (the run log at boot, and the | |
| 78 | * app's terminal, both of which are already privileged) and never to a | |
| 79 | * browser: a sign-in page that prints the password is not a sign-in page. | |
| 80 | */ | |
| 81 | export function ensureDemoPassword(): string | undefined { | |
| 82 | if (process.env.ADMIN_PASSWORD) return undefined; | |
| 83 | let existing = getSetting("admin_demo_password"); | |
| 84 | if (!existing) { | |
| 85 | existing = randomBytes(9).toString("base64url"); | |
| 86 | setSetting("admin_demo_password", existing); | |
| 87 | } | |
| 88 | return existing; | |
| 89 | } | |
| 90 | ||
| 91 | /** True when the password in use is the generated one, without saying what it is. */ | |
| 92 | export const usingGeneratedPassword = (): boolean => !process.env.ADMIN_PASSWORD; | |
| 93 | ||
| 94 | export function verifyAdminPassword(candidate: string): boolean { | |
| 95 | const expected = process.env.ADMIN_PASSWORD ?? ensureDemoPassword() ?? ""; | |
| 96 | if (expected.length === 0 || candidate.length === 0 || candidate.length > 200) return false; | |
| 97 | let salt = getSetting("admin_salt"); | |
| 98 | if (!salt) { | |
| 99 | salt = randomBytes(16).toString("base64url"); | |
| 100 | setSetting("admin_salt", salt); | |
| 101 | } | |
| 102 | return equals(derive(candidate, salt), derive(expected, salt)); | |
| 103 | } |
src/shop.ts +2 −2
| @@ -221,7 +221,7 @@ function openOrder(ctx: Ctx, tokenFrom: "query" | "form"): Order | null { | ||
| 221 | 221 | tokenFrom === "query" ? (ctx.url.searchParams.get("token") ?? "").slice(0, 64) : field(ctx.form, "token", 64); |
| 222 | 222 | const order = orders.byNumber(number); |
| 223 | 223 | if (!order) return null; |
| 224 | if (ctx.session.isAdmin) return order; | |
| 224 | if (ctx.who.role === "admin") return order; | |
| 225 | 225 | return token.length > 0 && equals(token, order.access_token) ? order : null; |
| 226 | 226 | } |
| 227 | 227 | |
| @@ -295,7 +295,7 @@ export async function orderPage(ctx: Ctx): Promise<Response> { | ||
| 295 | 295 | return render( |
| 296 | 296 | ctx, |
| 297 | 297 | { title: `Order ${order.number}` }, |
| 298 | pages.confirmation(order, orders.itemsOf(order.id), refunded, forOrder(order.id), ctx.session), | |
| 298 | pages.confirmation(order, orders.itemsOf(order.id), refunded, forOrder(order.id), ctx.who), | |
| 299 | 299 | ); |
| 300 | 300 | } |
| 301 | 301 |
src/style.ts +1
| @@ -36,6 +36,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus- | ||
| 36 | 36 | .btn.ghost { background: transparent; border-color: var(--line); color: inherit; } |
| 37 | 37 | .btn.danger { background: var(--warn); color: #fff; } |
| 38 | 38 | .btn[disabled] { opacity: .45; cursor: not-allowed; } |
| 39 | .who { white-space: nowrap; font-size: .9rem; display: flex; gap: .5rem; align-items: center; } | |
| 39 | 40 | .cart-link { white-space: nowrap; text-decoration: none; border: 1px solid var(--line); border-radius: 999px; padding: .35rem .8rem; font-size: .9rem; } |
| 40 | 41 | main { padding: 2rem 0 4rem; } |
| 41 | 42 | h1 { font-size: 1.8rem; letter-spacing: -.02em; margin: 0 0 .35rem; } |
src/tty.ts +22 −9
| @@ -3,6 +3,10 @@ | ||
| 3 | 3 | * the app, with FORGE_APP_DATA pointing at the same database the web pages |
| 4 | 4 | * use — the same catalog, the same orders, the same ledger. |
| 5 | 5 | * |
| 6 | * Who is at the keyboard comes from the forge too: FORGE_APP_USER, | |
| 7 | * FORGE_APP_USER_ID and FORGE_APP_USER_ROLE, named on the first screen. Moving | |
| 8 | * stock and refunding are an admin's to do, here as on the web. | |
| 9 | * | |
| 6 | 10 | * 1/2/3 switch between stock, orders and the outbox. j/k move, s moves stock |
| 7 | 11 | * with a reason, r refunds what is left of an order and puts it back on the |
| 8 | 12 | * shelf, q leaves. Plain ANSI, no dependencies. |
| @@ -14,12 +18,17 @@ import { formatCents } from "./money.ts"; | ||
| 14 | 18 | import { history, addStock, setStock } from "./inventory.ts"; |
| 15 | 19 | import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents } from "./orders.ts"; |
| 16 | 20 | import { outbox } from "./mail.ts"; |
| 17 | import { ensureDemoPassword } from "./session.ts"; | |
| 21 | import { atKeyboard } from "./who.ts"; | |
| 18 | 22 | import type { Order } from "./db.ts"; |
| 19 | 23 | |
| 20 | // Whoever reached this terminal already had to get through ssh, so this is | |
| 21 | // where a generated admin password is told. It is never put on a web page. | |
| 22 | const adminPassword = ensureDemoPassword(); | |
| 24 | const person = atKeyboard(); | |
| 25 | const isAdmin = person.role === "admin"; | |
| 26 | const whoLine = | |
| 27 | person.role === "anon" | |
| 28 | ? "the forge did not name you (anonymous) — stock moves and refunds are closed" | |
| 29 | : `signed in as ${person.name} (${person.id}) · ${person.role}${ | |
| 30 | isAdmin ? "" : " — stock moves and refunds are for an admin" | |
| 31 | }`; | |
| 23 | 32 | |
| 24 | 33 | const where = |
| 25 | 34 | process.env.FORGE_APP_ENV === "preview" |
| @@ -69,7 +78,7 @@ function draw(): void { | ||
| 69 | 78 | cursor = Math.min(cursor, Math.max(0, rows.length - 1)); |
| 70 | 79 | const lines: string[] = []; |
| 71 | 80 | lines.push(`${bold("bazaar")} ${dim(where)} ${dim(chosen().label)}`); |
| 72 | if (adminPassword) lines.push(dim(`/admin password (generated, set ADMIN_PASSWORD to replace it): ${adminPassword}`)); | |
| 81 | lines.push(dim(whoLine)); | |
| 73 | 82 | lines.push( |
| 74 | 83 | ["stock", "orders", "outbox"] |
| 75 | 84 | .map((name, i) => (name === tab ? bold(`${i + 1} ${name}`) : dim(`${i + 1} ${name}`))) |
| @@ -107,9 +116,9 @@ function draw(): void { | ||
| 107 | 116 | prompt === undefined |
| 108 | 117 | ? dim( |
| 109 | 118 | tab === "stock" |
| 110 | ? "1/2/3 tabs · j/k move · s move stock · h ledger · q quit" | |
| 119 | ? `1/2/3 tabs · j/k move${isAdmin ? " · s move stock" : ""} · h ledger · q quit` | |
| 111 | 120 | : tab === "orders" |
| 112 | ? "1/2/3 tabs · j/k move · r refund the rest and restock · q quit" | |
| 121 | ? `1/2/3 tabs · j/k move${isAdmin ? " · r refund the rest and restock" : ""} · q quit` | |
| 113 | 122 | : "1/2/3 tabs · j/k move · q quit", |
| 114 | 123 | ) |
| 115 | 124 | : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`, |
| @@ -175,8 +184,12 @@ process.stdin.on("data", (key: string) => { | ||
| 175 | 184 | if (key === "1") ((tab = "stock"), (cursor = 0)); |
| 176 | 185 | if (key === "2") ((tab = "orders"), (cursor = 0)); |
| 177 | 186 | if (key === "3") ((tab = "outbox"), (cursor = 0)); |
| 178 | if (key === "s" && tab === "stock") prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock }; | |
| 179 | if (key === "r" && tab === "orders") prompt = { label: "Refund the rest — reason:", text: "", run: refundRest }; | |
| 187 | if (key === "s" && tab === "stock") | |
| 188 | if (isAdmin) prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock }; | |
| 189 | else message = "Moving stock is for an admin."; | |
| 190 | if (key === "r" && tab === "orders") | |
| 191 | if (isAdmin) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest }; | |
| 192 | else message = "Refunding is for an admin."; | |
| 180 | 193 | if (key === "h" && tab === "stock") |
| 181 | 194 | message = history(8) |
| 182 | 195 | .map((entry) => dim(`${entry.at} ${entry.sku} ${entry.delta > 0 ? "+" : ""}${entry.delta} ${entry.reason}`)) |
src/units.test.ts +11 −1
| @@ -16,7 +16,7 @@ const catalog = await import("./catalog.ts"); | ||
| 16 | 16 | const discounts = await import("./discounts.ts"); |
| 17 | 17 | const giftcards = await import("./giftcards.ts"); |
| 18 | 18 | const tax = await import("./tax.ts"); |
| 19 | const { db } = await import("./db.ts"); | |
| 19 | const { db, forgetPasswordSettings, getSetting, setSetting } = await import("./db.ts"); | |
| 20 | 20 | |
| 21 | 21 | catalog.seed(); |
| 22 | 22 | discounts.seed(); |
| @@ -240,3 +240,13 @@ test("a bucket empties and refills", () => { | ||
| 240 | 240 | test("output is escaped", () => { |
| 241 | 241 | expect(escape(`<script>"&'`)).toBe("<script>"&'"); |
| 242 | 242 | }); |
| 243 | ||
| 244 | test("the settings the admin password left behind are deleted, and nothing else is", () => { | |
| 245 | setSetting("admin_demo_password", "whatever-it-was"); | |
| 246 | setSetting("admin_salt", "and-its-salt"); | |
| 247 | setSetting("tax_default_bp", "800"); | |
| 248 | forgetPasswordSettings(); | |
| 249 | expect(getSetting("admin_demo_password")).toBeUndefined(); | |
| 250 | expect(getSetting("admin_salt")).toBeUndefined(); | |
| 251 | expect(getSetting("tax_default_bp")).toBe("800"); | |
| 252 | }); |
src/who.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** The identity check, on its own: what the forge signs, and what it does not. */ | |
| 2 | ||
| 3 | import { expect, test } from "bun:test"; | |
| 4 | import { atKeyboard, stamp, whoIs } from "./who.ts"; | |
| 5 | ||
| 6 | const KEY = "a-key-only-the-forge-and-this-app-know"; | |
| 7 | ||
| 8 | /** Headers the way the forge sends them; the server tests use this shape too. */ | |
| 9 | const forge = (identity: string, key = KEY): Headers => | |
| 10 | new Headers({ "x-forge-identity": identity, "x-forge-signature": stamp(key, identity) }); | |
| 11 | ||
| 12 | const soon = (): number => Date.now() + 60_000; | |
| 13 | ||
| 14 | const withKey = <T>(key: string | undefined, run: () => T): T => { | |
| 15 | const had = process.env.FORGE_APP_IDENTITY_KEY; | |
| 16 | if (key === undefined) delete process.env.FORGE_APP_IDENTITY_KEY; | |
| 17 | else process.env.FORGE_APP_IDENTITY_KEY = key; | |
| 18 | try { | |
| 19 | return run(); | |
| 20 | } finally { | |
| 21 | if (had === undefined) delete process.env.FORGE_APP_IDENTITY_KEY; | |
| 22 | else process.env.FORGE_APP_IDENTITY_KEY = had; | |
| 23 | } | |
| 24 | }; | |
| 25 | ||
| 26 | test("a signed identity is the person the forge says it is", () => { | |
| 27 | withKey(KEY, () => { | |
| 28 | expect(whoIs(forge(`u_7|ada|admin|${soon()}`))).toEqual({ role: "admin", id: "u_7", name: "ada" }); | |
| 29 | expect(whoIs(forge(`u_8|bo|user|${soon()}`))).toEqual({ role: "user", id: "u_8", name: "bo" }); | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | test("anything that cannot be verified is anonymous", () => { | |
| 34 | withKey(KEY, () => { | |
| 35 | const anon = { role: "anon" }; | |
| 36 | // no headers at all | |
| 37 | expect(whoIs(new Headers())).toEqual(anon); | |
| 38 | // the identity without its signature, and the signature without its identity | |
| 39 | expect(whoIs(new Headers({ "x-forge-identity": `u_7|ada|admin|${soon()}` }))).toEqual(anon); | |
| 40 | expect(whoIs(new Headers({ "x-forge-signature": stamp(KEY, "x") }))).toEqual(anon); | |
| 41 | // signed with the wrong key, or the payload changed after signing | |
| 42 | expect(whoIs(forge(`u_7|ada|admin|${soon()}`, "not-the-forge"))).toEqual(anon); | |
| 43 | const tampered = forge(`u_7|ada|user|${soon()}`); | |
| 44 | tampered.set("x-forge-identity", `u_7|ada|admin|${soon()}`); | |
| 45 | expect(whoIs(tampered)).toEqual(anon); | |
| 46 | // shapes that do not parse | |
| 47 | expect(whoIs(forge(`u_7|ada|admin`))).toEqual(anon); | |
| 48 | expect(whoIs(forge(`u_7|ada|admin|${soon()}|extra`))).toEqual(anon); | |
| 49 | expect(whoIs(forge(`|ada|admin|${soon()}`))).toEqual(anon); | |
| 50 | expect(whoIs(forge(`u_7||admin|${soon()}`))).toEqual(anon); | |
| 51 | expect(whoIs(forge(`u_7|ada|owner|${soon()}`))).toEqual(anon); | |
| 52 | expect(whoIs(forge(`u_7|ada|ADMIN|${soon()}`))).toEqual(anon); | |
| 53 | expect(whoIs(forge(`u_7|ada|admin|soon`))).toEqual(anon); | |
| 54 | expect(whoIs(forge(`u_7|ada|admin|${soon()}.5`))).toEqual(anon); | |
| 55 | // an expiry that has passed | |
| 56 | expect(whoIs(forge(`u_7|ada|admin|${Date.now() - 1}`))).toEqual(anon); | |
| 57 | }); | |
| 58 | }); | |
| 59 | ||
| 60 | test("with no key nothing can be verified, so nobody is anybody", () => { | |
| 61 | const identity = `u_7|ada|admin|${soon()}`; | |
| 62 | const headers = withKey(KEY, () => forge(identity)); | |
| 63 | expect(withKey(undefined, () => whoIs(headers))).toEqual({ role: "anon" }); | |
| 64 | expect(withKey("", () => whoIs(headers))).toEqual({ role: "anon" }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("the terminal's person comes from the environment the forge set", () => { | |
| 68 | expect(atKeyboard({ FORGE_APP_USER: "ada", FORGE_APP_USER_ID: "u_7", FORGE_APP_USER_ROLE: "admin" })).toEqual({ | |
| 69 | role: "admin", | |
| 70 | id: "u_7", | |
| 71 | name: "ada", | |
| 72 | }); | |
| 73 | expect(atKeyboard({})).toEqual({ role: "anon" }); | |
| 74 | expect(atKeyboard({ FORGE_APP_USER: "ada", FORGE_APP_USER_ID: "u_7", FORGE_APP_USER_ROLE: "root" })).toEqual({ | |
| 75 | role: "anon", | |
| 76 | }); | |
| 77 | expect(atKeyboard({ FORGE_APP_USER: "", FORGE_APP_USER_ID: "u_7", FORGE_APP_USER_ROLE: "user" })).toEqual({ | |
| 78 | role: "anon", | |
| 79 | }); | |
| 80 | }); |
src/who.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** | |
| 2 | * Who is asking. Identity is the forge's to assert and ours to verify. | |
| 3 | * | |
| 4 | * The forge puts two headers on every request it proxies: `x-forge-identity`, | |
| 5 | * which is `<userId>|<username>|<role>|<expiresMs>`, and `x-forge-signature`, | |
| 6 | * which is base64url(HMAC-SHA256(FORGE_APP_IDENTITY_KEY, identity)). We | |
| 7 | * recompute the signature and compare it in constant time; anything we cannot | |
| 8 | * verify — no headers, no key, a shape that does not parse, an expiry that has | |
| 9 | * passed — is anonymous. There is no other way in: no password, no query | |
| 10 | * parameter, no environment flag, and nothing here reads a cookie. | |
| 11 | * | |
| 12 | * The app's own session cookie still exists, but it carries the CSRF token and | |
| 13 | * nothing else. Identity and CSRF are different jobs. | |
| 14 | */ | |
| 15 | ||
| 16 | import { equals, hmac } from "./sign.ts"; | |
| 17 | ||
| 18 | export type Role = "anon" | "user" | "admin"; | |
| 19 | ||
| 20 | export type Who = { role: "anon" } | { role: "user" | "admin"; id: string; name: string }; | |
| 21 | ||
| 22 | export const IDENTITY_HEADER = "x-forge-identity"; | |
| 23 | export const SIGNATURE_HEADER = "x-forge-signature"; | |
| 24 | ||
| 25 | export const ANON: Who = { role: "anon" }; | |
| 26 | ||
| 27 | /** The signature the forge puts on an identity string; the tests sign with it too. */ | |
| 28 | export const stamp = (key: string, identity: string): string => hmac(key, identity); | |
| 29 | ||
| 30 | /** The forge's headers, verified. Anything we cannot prove is `{ role: "anon" }`. */ | |
probe: checking the review API shape, ignore |
||
| 31 | export function whoIs(headers: Headers, now = Date.now()): Who { | |
| 32 | const key = process.env.FORGE_APP_IDENTITY_KEY; | |
| 33 | if (!key) return ANON; | |
| 34 | ||
| 35 | const identity = headers.get(IDENTITY_HEADER); | |
| 36 | const signature = headers.get(SIGNATURE_HEADER); | |
| 37 | if (!identity || !signature || identity.length > 512) return ANON; | |
| 38 | if (!equals(signature, stamp(key, identity))) return ANON; | |
| 39 | ||
| 40 | const fields = identity.split("|"); | |
| 41 | if (fields.length !== 4) return ANON; | |
| 42 | const [id, name, role, expiresMs] = fields as [string, string, string, string]; | |
| 43 | if (id === "" || name === "") return ANON; | |
| 44 | if (role !== "user" && role !== "admin") return ANON; | |
| 45 | if (!/^\d{1,15}$/.test(expiresMs) || Number(expiresMs) <= now) return ANON; | |
| 46 | return { role, id, name }; | |
| 47 | } | |
| 48 | ||
| 49 | /** False when no FORGE_APP_IDENTITY_KEY is set: then nobody can be an admin here. */ | |
| 50 | export const identityConfigured = (): boolean => Boolean(process.env.FORGE_APP_IDENTITY_KEY); | |
| 51 | ||
| 52 | /** | |
| 53 | * Who is at the keyboard in the app's terminal. There are no headers on an | |
| 54 | * ssh session: the forge names the person in the process environment before | |
| 55 | * it starts us, which is the same trust as the socket itself. | |
| 56 | */ | |
| 57 | export function atKeyboard(env: NodeJS.ProcessEnv = process.env): Who { | |
| 58 | const role = env.FORGE_APP_USER_ROLE; | |
| 59 | const id = env.FORGE_APP_USER_ID ?? ""; | |
| 60 | const name = env.FORGE_APP_USER ?? ""; | |
| 61 | if ((role !== "user" && role !== "admin") || id === "" || name === "") return ANON; | |
| 62 | return { role, id, name }; | |
| 63 | } | |
| 64 | ||
| 65 | /** Where a person goes to become someone: the forge's sign-in, and back here. */ | |
| 66 | export const signInHref = (to: string): string => `/__forge/signin?to=${encodeURIComponent(to)}`; | |
| 67 | export const SIGN_OUT_HREF = "/__forge/signout"; |
probe