Public: anyone can read this; the crew can change it.
Security: a board link is 41 bits of guessing — widen the id and put a brake on misses #125
scout/tempo:scout/72-security-a-board-link-is-41-bits-of-gues → main 2 commits fork
10 files
changed +190
−23
README.md +16 −1
| @@ -142,7 +142,12 @@ background, reverting if it fails. | ||
| 142 | 142 | in adds *attribution and a list you can find it in later*, not |
| 143 | 143 | privacy. Anyone with the link can still see and edit any board, |
| 144 | 144 | signed in or not — tempo never asks a visitor to sign in just to use |
| 145 | a board they were given the link to. | |
| 145 | a board they were given the link to. That id is 12 lowercase letters | |
| 146 | and digits — 36^12, about 4.7×10^18 possibilities, roughly 62 bits — | |
| 147 | drawn from the platform's CSPRNG without modulo bias (`randomId` in | |
| 148 | `src/db.ts`). A board made before this was widened kept its original | |
| 149 | 8-character id — 36^8, about 2.8×10^12, roughly 41 bits — and still | |
| 150 | works; every id check accepts either length. | |
| 146 | 151 | - tempo asks crawlers to stay out of `/b/` (and `/mcp`): every response |
| 147 | 152 | under those paths carries `x-robots-tag: noindex, nofollow, noarchive`, |
| 148 | 153 | a board page repeats it in a `<meta name="robots">`, and `/robots.txt` |
| @@ -219,6 +224,16 @@ and the disk, so `src/limits.ts` names a floor: | ||
| 219 | 224 | flood of new boards, *not* per-person fairness: costing a stranger's |
| 220 | 225 | flood is the whole point, and it's cheap to check before anything is |
| 221 | 226 | written. |
| 227 | - **Asking for a board that isn't there** is throttled the same way — a | |
| 228 | single, app-wide token bucket (20 a minute, `boardMissBucket` in | |
| 229 | `src/limits.ts`), because a plain read that answers 404 costs nothing to | |
| 230 | ask for again and again, and the id is this app's entire access control | |
| 231 | (see "What's simulated"). This is not per-caller fairness either, for | |
| 232 | the same reason `boardCreationBucket` isn't: a real board's own visitor | |
| 233 | never has occasion to ask for one that doesn't exist, so nothing | |
| 234 | legitimate ever spends from it, and turning a scanner's fast, free 404s | |
| 235 | into a slow trickle of 429s is the whole point. Past the bucket, a miss | |
| 236 | answers `429` with `retry-after` instead of another 404. | |
| 222 | 237 | - **Every other write** — a card or column added, moved, edited or |
| 223 | 238 | deleted, a board renamed, claimed or undone, and the two writing MCP |
| 224 | 239 | tools — is throttled at 300 a minute *per caller*: a signed-in write is |
src/db.test.ts +22
| @@ -196,6 +196,28 @@ test("a write from another process holding the write lock does not fail claimBoa | ||
| 196 | 196 | expect(db.getBoard(board.id)!.name).toBe("touched-by-other-process"); |
| 197 | 197 | }, 15000); |
| 198 | 198 | |
| 199 | test("a new board's id is 12 characters — the same length as a column or card's, not the old 8", () => { | |
| 200 | const board = db.createBoard("Board"); | |
| 201 | expect(board.id).toMatch(/^[a-z0-9]{12}$/); | |
| 202 | }); | |
| 203 | ||
| 204 | test("randomId draws from its alphabet without modulo bias", () => { | |
| 205 | const sample = db.randomId(200_000); | |
| 206 | const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; | |
| 207 | const counts = new Map<string, number>(); | |
| 208 | for (const ch of sample) counts.set(ch, (counts.get(ch) ?? 0) + 1); | |
| 209 | const expected = sample.length / alphabet.length; | |
| 210 | for (const ch of alphabet) { | |
| 211 | const count = counts.get(ch) ?? 0; | |
| 212 | // A biased draw (plain byte % 36) puts the alphabet's first 4 letters | |
| 213 | // at 8/256 instead of 7/256 — noticeably more than 10% high. A flat | |
| 214 | // draw's own sampling noise at this size is under 2%, so 10% either | |
| 215 | // way only catches a real bias, never a fluke. | |
| 216 | expect(count).toBeGreaterThan(expected * 0.9); | |
| 217 | expect(count).toBeLessThan(expected * 1.1); | |
| 218 | } | |
| 219 | }); | |
| 220 | ||
| 199 | 221 | test("every board returned by createBoard has exactly its three default columns", () => { |
| 200 | 222 | for (let i = 0; i < 5; i++) { |
| 201 | 223 | const board = db.createBoard(`Board ${i}`); |
src/db.ts +21 −9
| @@ -81,13 +81,24 @@ export type Card = { | ||
| 81 | 81 | updated_at: string; |
| 82 | 82 | }; |
| 83 | 83 | |
| 84 | // An id doubles as the unguessable part of a board's URL, so it comes | |
| 85 | // from the platform's CSPRNG, never Math.random. | |
| 84 | // An id doubles as the unguessable part of a board's URL, so it comes from | |
| 85 | // the platform's CSPRNG, never Math.random. 256 is not a multiple of | |
| 86 | // alphabet.length, so a plain `byte % alphabet.length` draws the first | |
| 87 | // `256 % alphabet.length` letters of the alphabet slightly more often than | |
| 88 | // the rest; rejecting any byte at or past the largest multiple of | |
| 89 | // alphabet.length that fits in a byte removes that bias, at the cost of | |
| 90 | // occasionally discarding a byte and drawing another. | |
| 86 | 91 | const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; |
| 87 | function randomId(length: number): string { | |
| 88 | const bytes = new Uint8Array(length); | |
| 89 | crypto.getRandomValues(bytes); | |
| 90 | return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join(""); | |
| 92 | const REJECT_AT = alphabet.length * Math.floor(256 / alphabet.length); | |
| 93 | export function randomId(length: number): string { | |
| 94 | const byte = new Uint8Array(1); | |
| 95 | let id = ""; | |
| 96 | while (id.length < length) { | |
| 97 | crypto.getRandomValues(byte); | |
| 98 | if (byte[0] >= REJECT_AT) continue; | |
| 99 | id += alphabet[byte[0] % alphabet.length]; | |
| 100 | } | |
| 101 | return id; | |
| 91 | 102 | } |
| 92 | 103 | |
| 93 | 104 | type Payload = { |
| @@ -136,8 +147,9 @@ function logAction(boardId: string, label: string, payload: Omit<Payload, "label | ||
| 136 | 147 | // revision is a counter bumped in the same transaction as every write that |
| 137 | 148 | // changes what that scope shows, never derived from the action log — the |
| 138 | 149 | // log is trimmed and undone, so it is not a clock. |
| 139 | // "__boards__" is not a valid board id (those are 8 lowercase/digit | |
| 140 | // characters from randomId), so it cannot collide with one. | |
| 150 | // "__boards__" is not a valid board id (those are 8 or 12 lowercase/digit | |
| 151 | // characters from randomId — 8 from before a board link's id was widened, | |
| 152 | // 12 since; see "Limits" in the README), so it cannot collide with one. | |
| 141 | 153 | const BOARDS_SCOPE = "__boards__"; |
| 142 | 154 | |
| 143 | 155 | function bumpRevision(scope: string): void { |
| @@ -165,7 +177,7 @@ const BOARD_FIELDS = "id, name, owner_id, owner_name, created_at"; | ||
| 165 | 177 | |
| 166 | 178 | export function createBoard(name: string, owner?: { id: string; name: string }): Board { |
| 167 | 179 | return db.transaction((): Board => { |
| 168 | const id = randomId(8); | |
| 180 | const id = randomId(12); | |
| 169 | 181 | db.query(`INSERT INTO board (id, name, owner_id, owner_name) VALUES (?1, ?2, ?3, ?4)`).run( |
| 170 | 182 | id, |
| 171 | 183 | name, |
src/keys.ts +4 −2
| @@ -77,8 +77,10 @@ export function visibleBoards(board: BoardStore, ownerId: string | null): Board[ | ||
| 77 | 77 | return ownerId === null ? [] : board.listBoardsByOwner(ownerId); |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | /** A board id as `src/db.ts` mints them: the only text `o` will look up. */ | |
| 81 | const boardIdRe = /^[a-z0-9]{8}$/; | |
| 80 | // A board id as `src/db.ts` mints them, the only text `o` will look up: 12 | |
| 81 | // characters now, or 8 from before a board link's id was widened (see | |
| 82 | // README, "Limits") — both still open. | |
| 83 | const boardIdRe = /^[a-z0-9]{8}([a-z0-9]{4})?$/; | |
| 82 | 84 | |
| 83 | 85 | /** The cards of one column that the current filter leaves showing. */ |
| 84 | 86 | export function visibleCards(board: BoardStore, state: State, columnId: string): Card[] { |
src/limits.test.ts +21 −1
| @@ -2,7 +2,18 @@ import { beforeEach, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { boardCreationBucket, callerKey, KeyedTokenBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_UNDO_DEPTH, resetLimits, TokenBucket, writeBucket } from "./limits.ts"; | |
| 5 | import { | |
| 6 | boardCreationBucket, | |
| 7 | boardMissBucket, | |
| 8 | callerKey, | |
| 9 | KeyedTokenBucket, | |
| 10 | MAX_CARDS_PER_BOARD, | |
| 11 | MAX_COLUMNS_PER_BOARD, | |
| 12 | MAX_UNDO_DEPTH, | |
| 13 | resetLimits, | |
| 14 | TokenBucket, | |
| 15 | writeBucket, | |
| 16 | } from "./limits.ts"; | |
| 6 | 17 | import { ANON } from "./who.ts"; |
| 7 | 18 | |
| 8 | 19 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| @@ -126,6 +137,15 @@ test("writeBucket is its own bucket, separate from board creation's: draining on | ||
| 126 | 137 | expect(boardCreationBucket.take()).toBe(true); |
| 127 | 138 | }); |
| 128 | 139 | |
| 140 | test("boardMissBucket is its own bucket, separate from writes and board creation", () => { | |
| 141 | while (boardMissBucket.take()) { | |
| 142 | /* spend every token boardMissBucket has */ | |
| 143 | } | |
| 144 | expect(writeBucket.take("miss-drain-test")).toBe(true); | |
| 145 | expect(boardCreationBucket.take()).toBe(true); | |
| 146 | boardMissBucket.reset(); | |
| 147 | }); | |
| 148 | ||
| 129 | 149 | test("a keyed bucket refuses one caller without touching another's tokens", () => { |
| 130 | 150 | let now = 0; |
| 131 | 151 | const bucket = new KeyedTokenBucket(3, 10, () => now); |
src/limits.ts +10
| @@ -112,6 +112,15 @@ export class TokenBucket { | ||
| 112 | 112 | // written, that a single app-wide brake is enough — see README. |
| 113 | 113 | export const boardCreationBucket = new TokenBucket(MAX_BOARDS_CREATED_PER_MINUTE); |
| 114 | 114 | |
| 115 | // Asking for a board that isn't there costs nothing today — a plain read, | |
| 116 | // no write to even fail — so nothing before this stood between a scanner | |
| 117 | // and asking as fast as the network allows. A real board's own visitor has | |
| 118 | // no occasion to ever ask for one that doesn't exist, so nothing legitimate | |
| 119 | // spends from this; a single app-wide bucket, same reasoning as | |
| 120 | // boardCreationBucket above, not per-caller fairness (see README). | |
| 121 | export const MAX_BOARD_MISSES_PER_MINUTE = 20; | |
| 122 | export const boardMissBucket = new TokenBucket(MAX_BOARD_MISSES_PER_MINUTE); | |
| 123 | ||
| 115 | 124 | /** |
| 116 | 125 | * One TokenBucket per caller, so a stranger flooding writes only ever |
| 117 | 126 | * spends their own bucket dry, never anyone else's — the mistake an |
| @@ -186,6 +195,7 @@ export const writeBucket = new KeyedTokenBucket(MAX_WRITES_PER_MINUTE, MAX_WRITE | ||
| 186 | 195 | */ |
| 187 | 196 | export function resetLimits(): void { |
| 188 | 197 | boardCreationBucket.reset(); |
| 198 | boardMissBucket.reset(); | |
| 189 | 199 | writeBucket.reset(); |
| 190 | 200 | } |
| 191 | 201 |
src/mcp.ts +3 −1
| @@ -21,7 +21,9 @@ function publish(boardId: string, who: Who, kind: bus.EventKind, ids: Record<str | ||
| 21 | 21 | noteRevision(boardId, db.boardRevision(boardId)); |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | const BOARD_ID = /^[a-z0-9]{8}$/; | |
| 24 | // 8 characters from before a board link's id was widened to 12; both still | |
| 25 | // work (see README, "Limits"). | |
| 26 | const BOARD_ID = /^[a-z0-9]{8}([a-z0-9]{4})?$/; | |
| 25 | 27 | const ENTITY_ID = /^[a-z0-9]{12}$/; |
| 26 | 28 | |
| 27 | 29 | type Result<T> = { ok: true; value: T } | { ok: false; error: string; message: string }; |
src/routes.ts +28 −5
| @@ -7,7 +7,15 @@ | ||
| 7 | 7 | import * as bus from "./bus.ts"; |
| 8 | 8 | import type { EventKind } from "./bus.ts"; |
| 9 | 9 | import * as db from "./db.ts"; |
| 10 | import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD, writeBucket } from "./limits.ts"; | |
| 10 | import { | |
| 11 | boardCreationBucket, | |
| 12 | boardMissBucket, | |
| 13 | callerKey, | |
| 14 | MAX_CARDS_PER_BOARD, | |
| 15 | MAX_COLUMNS_PER_BOARD, | |
| 16 | MAX_SUBSCRIBERS_PER_BOARD, | |
| 17 | writeBucket, | |
| 18 | } from "./limits.ts"; | |
| 11 | 19 | import { rpc } from "./mcp.ts"; |
| 12 | 20 | import { boardPage, landingPage } from "./pages/board.ts"; |
| 13 | 21 | import * as presence from "./presence.ts"; |
| @@ -16,7 +24,9 @@ import { cleanText } from "./text.ts"; | ||
| 16 | 24 | import { noteRevision } from "./watch.ts"; |
| 17 | 25 | import type { Who } from "./who.ts"; |
| 18 | 26 | |
| 19 | const BOARD_ID = /^[a-z0-9]{8}$/; | |
| 27 | // 8 characters from before a board link's id was widened to 12; both still | |
| 28 | // work (see README, "Limits"). | |
| 29 | const BOARD_ID = /^[a-z0-9]{8}([a-z0-9]{4})?$/; | |
| 20 | 30 | const ENTITY_ID = /^[a-z0-9]{12}$/; |
| 21 | 31 | |
| 22 | 32 | // The client that made a write, so its own tab can ignore the echo of a |
| @@ -138,6 +148,19 @@ function writeRefused(request: Request, who: Who, boardId: string, key: string): | ||
| 138 | 148 | return boardBad(request, who, boardId, "too many changes just now — try again shortly", 429, { "retry-after": retryAfter }); |
| 139 | 149 | } |
| 140 | 150 | |
| 151 | // A well-formed id naming no board is a miss — a stale link or a guess, | |
| 152 | // indistinguishable from here — and it spends one of boardMissBucket's | |
| 153 | // tokens (src/limits.ts): plenty for the rare stale link, not for a scan | |
| 154 | // asking as fast as the network allows. Once the bucket is dry this answers | |
| 155 | // 429 instead of another 404, so a scanner can no longer ask for free. | |
| 156 | function boardMiss(): Response { | |
| 157 | if (boardMissBucket.take()) return new Response("board not found", { status: 404 }); | |
| 158 | return new Response("too many missing boards asked for just now — try again shortly", { | |
| 159 | status: 429, | |
| 160 | headers: { "retry-after": String(boardMissBucket.retryAfterSeconds()) }, | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 141 | 164 | |
| 142 | 165 | // A missing content-type is left alone: it parses as an empty form today |
| 143 | 166 | // (a bare `curl -X POST` with no body), and neither the board script nor a |
| @@ -284,7 +307,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s | ||
| 284 | 307 | if (parts.length === 2) { |
| 285 | 308 | if (method !== "GET") return methodNotAllowed("GET, HEAD"); |
| 286 | 309 | const board = db.getBoard(boardId); |
| 287 | if (!board) return new Response("board not found", { status: 404 }); | |
| 310 | if (!board) return boardMiss(); | |
| 288 | 311 | const columns = db.listColumns(boardId); |
| 289 | 312 | const cards = db.listCards(boardId); |
| 290 | 313 | const query = url.searchParams.get("q") ?? ""; |
| @@ -292,11 +315,11 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s | ||
| 292 | 315 | } |
| 293 | 316 | |
| 294 | 317 | if (method === "GET" && parts.length === 3 && parts[2] === "events") { |
| 295 | if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 }); | |
| 318 | if (!db.getBoard(boardId)) return boardMiss(); | |
| 296 | 319 | return eventStream(boardId, request, who); |
| 297 | 320 | } |
| 298 | 321 | |
| 299 | if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 }); | |
| 322 | if (!db.getBoard(boardId)) return boardMiss(); | |
| 300 | 323 | |
| 301 | 324 | if (method === "POST" && parts.length === 3 && parts[2] === "claim") { |
| 302 | 325 | if (who.role === "anon") return new Response("sign in to keep a board", { status: 401 }); |
src/server.test.ts +55 −4
| @@ -8,9 +8,17 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | ||
| 8 | 8 | process.env.PORT = "0"; |
| 9 | 9 | process.env.FORGE_APP_IDENTITY_KEY = "test-key"; |
| 10 | 10 | const { server } = await import("./server.ts"); |
| 11 | const { sign } = await import("./who.ts"); | |
| 12 | const { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_REQUEST_BODY_BYTES, MAX_SUBSCRIBERS_PER_BOARD, resetLimits } = | |
| 13 | await import("./limits.ts"); | |
| 11 | const { route } = await import("./routes.ts"); | |
| 12 | const { sign, ANON } = await import("./who.ts"); | |
| 13 | const { | |
| 14 | MAX_BOARD_MISSES_PER_MINUTE, | |
| 15 | MAX_CARDS_PER_BOARD, | |
| 16 | MAX_COLUMNS_PER_BOARD, | |
| 17 | MAX_REQUEST_BODY_BYTES, | |
| 18 | MAX_SUBSCRIBERS_PER_BOARD, | |
| 19 | boardMissBucket, | |
| 20 | resetLimits, | |
| 21 | } = await import("./limits.ts"); | |
| 14 | 22 | const db = await import("./db.ts"); |
| 15 | 23 | const { THEME_JS } = await import("./pages/board.ts"); |
| 16 | 24 | // The rate limits are process-wide state shared by every test file in one |
| @@ -112,7 +120,7 @@ test("creates a board with default columns and a shareable URL", async () => { | ||
| 112 | 120 | const created = await fetch(base + "/boards", { method: "POST", body: new URLSearchParams({ name: "Launch" }), redirect: "manual" }); |
| 113 | 121 | expect(created.status).toBe(303); |
| 114 | 122 | const location = created.headers.get("location")!; |
| 115 | expect(location).toMatch(/^\/b\/[a-z0-9]{8}$/); | |
| 123 | expect(location).toMatch(/^\/b\/[a-z0-9]{12}$/); | |
| 116 | 124 | const page = await (await fetch(base + location)).text(); |
| 117 | 125 | expect(page).toContain("Launch"); |
| 118 | 126 | expect(page).toContain("To do"); |
| @@ -120,6 +128,27 @@ test("creates a board with default columns and a shareable URL", async () => { | ||
| 120 | 128 | expect(page).toContain("Done"); |
| 121 | 129 | }); |
| 122 | 130 | |
| 131 | test("a board with the old 8-character id still opens, through the page and through MCP", async () => { | |
| 132 | // A board created before ids were widened to 12 characters (issue #72) — | |
| 133 | // inserted directly, since db.createBoard only ever mints 12 now. | |
| 134 | const legacyId = "legacy8x"; | |
| 135 | db.db.query("INSERT INTO board (id, name) VALUES (?1, ?2)").run(legacyId, "Legacy board"); | |
| 136 | const columnId = db.createColumn(legacyId, "To do"); | |
| 137 | if (typeof columnId === "string") throw new Error("expected a Column"); | |
| 138 | ||
| 139 | const page = await fetch(base + `/b/${legacyId}`); | |
| 140 | expect(page.status).toBe(200); | |
| 141 | expect(await page.text()).toContain("Legacy board"); | |
| 142 | ||
| 143 | const rpcCall = (body: unknown) => | |
| 144 | fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 145 | const listed = await ( | |
| 146 | await rpcCall({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_columns", arguments: { board_id: legacyId } } }) | |
| 147 | ).json(); | |
| 148 | const columns = JSON.parse(listed.result.content[0].text); | |
| 149 | expect(columns.map((c: { name: string }) => c.name)).toEqual(["To do"]); | |
| 150 | }); | |
| 151 | ||
| 123 | 152 | test("404s for an unknown board", async () => { |
| 124 | 153 | const response = await fetch(base + "/b/zzzzzzzz"); |
| 125 | 154 | expect(response.status).toBe(404); |
| @@ -130,6 +159,28 @@ test("rejects a malformed board id", async () => { | ||
| 130 | 159 | expect(response.status).toBe(404); |
| 131 | 160 | }); |
| 132 | 161 | |
| 162 | // route() directly, not fetch: boardMissBucket refills off the real clock | |
| 163 | // (see limits.ts), and a socket round trip is enough time for a token to | |
| 164 | // trickle back at this bucket's rate — same reasoning as write-limit.test.ts. | |
| 165 | test("scanning for boards that don't exist runs the bucket dry: a miss costs 429, not another free 404", async () => { | |
| 166 | boardMissBucket.reset(); | |
| 167 | const miss = () => { | |
| 168 | const request = new Request(base + "/b/zzzzzzzzzzzz"); | |
| 169 | return route(request, new URL(request.url), "test", ANON, "203.0.113.9").then((r) => r!); | |
| 170 | }; | |
| 171 | ||
| 172 | for (let i = 0; i < MAX_BOARD_MISSES_PER_MINUTE; i++) { | |
| 173 | const response = await miss(); | |
| 174 | expect(response.status).toBe(404); | |
| 175 | } | |
| 176 | ||
| 177 | const refused = await miss(); | |
| 178 | expect(refused.status).toBe(429); | |
| 179 | expect(refused.headers.get("retry-after")).toMatch(/^\d+$/); | |
| 180 | ||
| 181 | boardMissBucket.reset(); | |
| 182 | }); | |
| 183 | ||
| 133 | 184 | test("adds, moves, edits and deletes a card, and undo reverses the last change", async () => { |
| 134 | 185 | const id = await newBoard(); |
| 135 | 186 | const boardUrl = `/b/${id}`; |
src/tty.test.ts +10
| @@ -109,6 +109,16 @@ test("o opens any board by id, kept or not, regardless of who is at the keyboard | ||
| 109 | 109 | expect(opened.boardId).toBe(kept.id); |
| 110 | 110 | }); |
| 111 | 111 | |
| 112 | test("o opens a board that still has the old 8-character id, the same as a 12-character one", () => { | |
| 113 | // A board created before ids were widened to 12 characters (issue #72) — | |
| 114 | // inserted directly, since db.createBoard only ever mints 12 now. | |
| 115 | db.db.query("INSERT INTO board (id, name) VALUES (?1, ?2)").run("legacy8c", "Legacy"); | |
| 116 | ||
| 117 | const opened = press(initialState(), "o", ...type("legacy8c"), "\r"); | |
| 118 | expect(opened.mode).toBe("board"); | |
| 119 | expect(opened.boardId).toBe("legacy8c"); | |
| 120 | }); | |
| 121 | ||
| 112 | 122 | test("o with an id that is not a board, or not an id at all, stays in the list", () => { |
| 113 | 123 | for (const text of ["", "nosuchbd", "../../etc", "'; drop table board--"]) { |
| 114 | 124 | const after = press(initialState(), "o", ...type(text), "\r"); |