Public: anyone can read this; the crew can change it.
propose refuses an option that is not a string, rather than coercing it #41
Check · Review · Land · Live done — Landed and live in production.
ledger/quorum:ledger/17-propose-refuses-an-option-that-is-not-a → main 1 commit fork
3 files
changed +24
−2
README.md +3 −1
| @@ -112,7 +112,9 @@ it, or `off`. At boot the app prints one line per misconfiguration — the name | ||
| 112 | 112 | of the variable and what it costs, never its value. |
| 113 | 113 | |
| 114 | 114 | A proposal is a title (1–120 characters), a paragraph (1–2000) and two to |
| 115 | eight options, each labelled, each different. Its author or an admin can | |
| 115 | eight options, each labelled, each different. Every option is a line of text: | |
| 116 | a JSON or MCP caller that sends a number, an object or a list in that array is | |
| 117 | refused rather than having it coerced into a label. Its author or an admin can | |
| 116 | 118 | withdraw it while it is open, once. |
| 117 | 119 | |
| 118 | 120 | `rule` says how it will be decided. The proposer picks it when they open the |
src/actions.test.ts +16
| @@ -82,6 +82,7 @@ test("a proposal has two to eight distinct, labelled options", () => { | ||
| 82 | 82 | ["a blank label", ["Yes", " "]], |
| 83 | 83 | ["a label of eighty-one characters", ["Yes", "x".repeat(81)]], |
| 84 | 84 | ["nothing at all", []], |
| 85 | ["an option that is not a string", [{}, ["x"]]], | |
| 85 | 86 | ]; |
| 86 | 87 | for (const [what, options] of bad) { |
| 87 | 88 | const r = propose(db, alice, { ...good, options } as typeof good); |
| @@ -97,6 +98,21 @@ test("a proposal has two to eight distinct, labelled options", () => { | ||
| 97 | 98 | expect(eight.options.length).toBe(8); |
| 98 | 99 | }); |
| 99 | 100 | |
| 101 | test("an option that is not a line of text is named, not coerced", () => { | |
| 102 | const r = propose(db, alice, { ...good, options: [{}, ["x"]] } as never); | |
| 103 | expect(r.ok).toBe(false); | |
| 104 | if (!r.ok) { | |
| 105 | expect(r.error).toBe("invalid"); | |
| 106 | expect(r.message).toBe("Every option is a line of text."); | |
| 107 | } | |
| 108 | // Said before the count and the duplicates: the shape is the first thing | |
| 109 | // wrong, and a coerced label is never what the caller meant. | |
| 110 | for (const options of [[{}], [null, null], [1, 1]]) { | |
| 111 | const one = propose(db, alice, { ...good, options } as never); | |
| 112 | expect(!one.ok && one.message).toBe("Every option is a line of text."); | |
| 113 | } | |
| 114 | }); | |
| 115 | ||
| 100 | 116 | test("a title is at most a hundred and twenty characters, a paragraph two thousand", () => { |
| 101 | 117 | for (const input of [ |
| 102 | 118 | { title: "" }, |
src/actions.ts +5 −1
| @@ -190,7 +190,11 @@ export function propose( | ||
| 190 | 190 | "invalid", |
| 191 | 191 | `A proposal has ${OPTIONS_MIN} to ${OPTIONS_MAX} options, one per line.`, |
| 192 | 192 | ); |
| 193 | const labels = input.options.map((label) => String(label ?? "").trim()); | |
| 193 | // A caller that sends an object or a list where a line of text belongs is | |
| 194 | // told so, rather than having it coerced into a label nobody wrote. | |
| 195 | if (input.options.some((label) => typeof label !== "string")) | |
| 196 | return fail("invalid", "Every option is a line of text."); | |
| 197 | const labels = input.options.map((label) => String(label).trim()); | |
| 194 | 198 | if (labels.length < OPTIONS_MIN || labels.length > OPTIONS_MAX) |
| 195 | 199 | return fail( |
| 196 | 200 | "invalid", |