Public: anyone can read this; the crew can change it.
The roster, part one: the member table and the three actions #42
Check · Review · Land · Live done — Landed and live in production.
ledger/quorum:ledger/27-the-roster-part-one-the-member-table-and → main 3 commits fork
5 files
changed +376
−2
src/actions.test.ts +135 −1
| @@ -4,20 +4,27 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { |
| 6 | 6 | acceptCounter, |
| 7 | addMember, | |
| 7 | 8 | addRemark, |
| 8 | 9 | canDeleteRemark, |
| 9 | 10 | canWithdraw, |
| 10 | 11 | deleteRemark, |
| 11 | 12 | getProposal, |
| 12 | 13 | health, |
| 14 | isMember, | |
| 15 | listMembers, | |
| 13 | 16 | listProposals, |
| 14 | 17 | listRemarks, |
| 18 | MEMBER_NAME_MAX, | |
| 19 | PERSON_ID_MAX, | |
| 15 | 20 | propose, |
| 21 | removeMember, | |
| 22 | ROSTER_MAX, | |
| 16 | 23 | withdrawProposal, |
| 17 | 24 | type AdminHealth, |
| 18 | 25 | } from "./actions.ts"; |
| 19 | 26 | import { migrations, openDb } from "./db.ts"; |
| 20 | import { useDb } from "./live.ts"; | |
| 27 | import { subscribe, useDb, type LiveEvent } from "./live.ts"; | |
| 21 | 28 | import type { Rule } from "./quorum.ts"; |
| 22 | 29 | import type { Who } from "./who.ts"; |
| 23 | 30 | |
| @@ -580,3 +587,130 @@ test("a schema behind the migration list is not ok", () => { | ||
| 580 | 587 | expect(r.value.schemaVersion).toBe(migrations.length - 1); |
| 581 | 588 | behind.close(); |
| 582 | 589 | }); |
| 590 | ||
| 591 | // ── The roster ──────────────────────────────────────────────────────────── | |
| 592 | // Reading is open; writing is an admin's. A member is a person id, once. | |
| 593 | ||
| 594 | const roster = () => { | |
| 595 | const r = listMembers(db, anon); | |
| 596 | if (!r.ok) throw new Error(r.message); | |
| 597 | return r.value; | |
| 598 | }; | |
| 599 | ||
| 600 | test("only an admin changes the roster; anyone may read it", () => { | |
| 601 | expect(roster()).toEqual([]); | |
| 602 | ||
| 603 | const guest = addMember(db, anon, { personId: "u1", name: "alice" }); | |
| 604 | expect(guest.ok).toBe(false); | |
| 605 | if (!guest.ok) expect(guest.error).toBe("signin_required"); | |
| 606 | const user = addMember(db, alice, { personId: "u1", name: "alice" }); | |
| 607 | expect(user.ok).toBe(false); | |
| 608 | if (!user.ok) expect(user.error).toBe("forbidden"); | |
| 609 | const removing = removeMember(db, bob, { personId: "u1" }); | |
| 610 | expect(removing.ok).toBe(false); | |
| 611 | if (!removing.ok) expect(removing.error).toBe("forbidden"); | |
| 612 | // Both doors refuse a guest the same way, and say so before anything is read. | |
| 613 | const removingGuest = removeMember(db, anon, { personId: "u1" }); | |
| 614 | expect(removingGuest.ok).toBe(false); | |
| 615 | if (!removingGuest.ok) expect(removingGuest.error).toBe("signin_required"); | |
| 616 | expect(roster()).toEqual([]); | |
| 617 | ||
| 618 | const heard: LiveEvent[] = []; | |
| 619 | const stop = subscribe((event) => heard.push(event)); | |
| 620 | const added = addMember(db, root, { | |
| 621 | personId: " u1 ", | |
| 622 | name: " alice ", | |
| 623 | }); | |
| 624 | expect(added.ok).toBe(true); | |
| 625 | if (!added.ok) return; | |
| 626 | expect(added.value.person_id).toBe("u1"); | |
| 627 | expect(added.value.name).toBe("alice"); | |
| 628 | // Who put them there is kept: an admin assigns, the roster remembers which. | |
| 629 | expect(added.value.added_by).toBe(root.id); | |
| 630 | expect(heard.map((e) => e.type)).toEqual(["member.added"]); | |
| 631 | ||
| 632 | // Again is not an error, and not a second row. | |
| 633 | const twice = addMember(db, root, { personId: "u1", name: "alice again" }); | |
| 634 | expect(twice.ok).toBe(true); | |
| 635 | if (twice.ok) expect(twice.value.id).toBe(added.value.id); | |
| 636 | expect(roster().map((m) => m.person_id)).toEqual(["u1"]); | |
| 637 | expect(roster()[0]?.name).toBe("alice"); | |
| 638 | expect(heard.map((e) => e.type)).toEqual(["member.added"]); | |
| 639 | ||
| 640 | // A guest reads the same roster the admin does. | |
| 641 | expect(listMembers(db, anon)).toEqual(listMembers(db, root)); | |
| 642 | ||
| 643 | const gone = removeMember(db, root, { personId: "u1" }); | |
| 644 | expect(gone.ok).toBe(true); | |
| 645 | expect(roster()).toEqual([]); | |
| 646 | expect(heard.map((e) => e.type)).toEqual(["member.added", "member.removed"]); | |
| 647 | expect((heard[1]?.data as { person_id: string }).person_id).toBe("u1"); | |
| 648 | stop(); | |
| 649 | }); | |
| 650 | ||
| 651 | test("a person id is an id and a name is a name", () => { | |
| 652 | const cases: [string, string][] = [ | |
| 653 | ["u1/../root", "alice"], | |
| 654 | ["u 1", "alice"], | |
| 655 | ["", "alice"], | |
| 656 | ["<script>", "alice"], | |
| 657 | ["u".repeat(PERSON_ID_MAX + 1), "alice"], | |
| 658 | ["u1", ""], | |
| 659 | ["u1", " "], | |
| 660 | ["u1", "n".repeat(MEMBER_NAME_MAX + 1)], | |
| 661 | ]; | |
| 662 | for (const [personId, name] of cases) { | |
| 663 | const r = addMember(db, root, { personId, name }); | |
| 664 | expect(r.ok).toBe(false); | |
| 665 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 666 | } | |
| 667 | // The longest allowed pair is allowed. | |
| 668 | const edge = addMember(db, root, { | |
| 669 | personId: "u".repeat(PERSON_ID_MAX), | |
| 670 | name: "n".repeat(MEMBER_NAME_MAX), | |
| 671 | }); | |
| 672 | expect(edge.ok).toBe(true); | |
| 673 | if (edge.ok) removeMember(db, root, { personId: edge.value.person_id }); | |
| 674 | expect(roster()).toEqual([]); | |
| 675 | }); | |
| 676 | ||
| 677 | test("removing somebody who is not there is not_found", () => { | |
| 678 | const r = removeMember(db, root, { personId: "nobody" }); | |
| 679 | expect(r.ok).toBe(false); | |
| 680 | if (!r.ok) expect(r.error).toBe("not_found"); | |
| 681 | }); | |
| 682 | ||
| 683 | test("the roster stops at its cap, by name", () => { | |
| 684 | const full = openDb(mkdtempSync(join(tmpdir(), "app-roster-"))); | |
| 685 | for (let i = 0; i < ROSTER_MAX; i++) { | |
| 686 | const r = addMember(full, root, { personId: `p${i}`, name: `person ${i}` }); | |
| 687 | expect(r.ok).toBe(true); | |
| 688 | } | |
| 689 | const over = addMember(full, root, { personId: "one-too-many", name: "zed" }); | |
| 690 | expect(over.ok).toBe(false); | |
| 691 | if (!over.ok) expect(over.error).toBe("roster_full"); | |
| 692 | // Somebody already on it still comes back, cap or no cap. | |
| 693 | const known = addMember(full, root, { personId: "p0", name: "person 0" }); | |
| 694 | expect(known.ok).toBe(true); | |
| 695 | full.close(); | |
| 696 | }); | |
| 697 | ||
| 698 | test("isMember is the roster's answer, not the role's", () => { | |
| 699 | expect(isMember(db, anon)).toBe(false); | |
| 700 | expect(isMember(db, alice)).toBe(false); | |
| 701 | // An admin who was never added does not count either: writing is a | |
| 702 | // permission, counting toward a quorum is a row. | |
| 703 | expect(isMember(db, root)).toBe(false); | |
| 704 | ||
| 705 | const added = addMember(db, root, { personId: alice.id, name: "alice" }); | |
| 706 | expect(added.ok).toBe(true); | |
| 707 | expect(isMember(db, alice)).toBe(true); | |
| 708 | expect(isMember(db, bob)).toBe(false); | |
| 709 | expect(isMember(db, anon)).toBe(false); | |
| 710 | // The name may change; the id is what counts. | |
| 711 | expect(isMember(db, { role: "user", id: alice.id, name: "renamed" })).toBe( | |
| 712 | true, | |
| 713 | ); | |
| 714 | removeMember(db, root, { personId: alice.id }); | |
| 715 | expect(isMember(db, alice)).toBe(false); | |
| 716 | }); |
src/actions.ts +146 −1
| @@ -91,7 +91,12 @@ export interface Remark { | ||
| 91 | 91 | readonly created_at: string; |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 94 | export type Failure = | |
| 95 | | "signin_required" | |
| 96 | | "forbidden" | |
| 97 | | "not_found" | |
| 98 | | "invalid" | |
| 99 | | "roster_full"; | |
| 95 | 100 | export type Result<T> = |
| 96 | 101 | | { ok: true; value: T } |
| 97 | 102 | | { ok: false; error: Failure; message: string }; |
| @@ -442,6 +447,146 @@ const exists = (db: Database, id: number): boolean => | ||
| 442 | 447 | .query<{ id: number }, [number]>("SELECT id FROM proposal WHERE id = ?1") |
| 443 | 448 | .get(id) !== null; |
| 444 | 449 | |
| 450 | /** | |
| 451 | * The roster: who counts toward a quorum. A member is a person the forge | |
| 452 | * knows (`person_id`), and that id is the whole identity — `name` is only | |
| 453 | * what to show, so a rename never drops anybody. Reading is open to | |
| 454 | * everyone; only an admin writes, and that rule lives here, so it holds at | |
| 455 | * whatever door calls next. | |
| 456 | */ | |
| 457 | export interface Member { | |
| 458 | readonly id: number; | |
| 459 | readonly person_id: string; | |
| 460 | readonly name: string; | |
| 461 | readonly added_by: string; | |
| 462 | readonly created_at: string; | |
| 463 | } | |
| 464 | ||
| 465 | export const PERSON_ID_MAX = 64; | |
| 466 | export const MEMBER_NAME_MAX = 64; | |
| 467 | /** A group that decides things together is small; past this it is a mailing list. */ | |
| 468 | export const ROSTER_MAX = 256; | |
| 469 | /** The characters a forge id is made of. Anything else is not an id we were given. */ | |
| 470 | const PERSON_ID = /^[A-Za-z0-9_.-]+$/; | |
| 471 | ||
| 472 | /** A person's own row, or nothing. The id is what is matched, never the name. */ | |
| 473 | const memberRow = (db: Database, personId: string): Member | null => | |
| 474 | db | |
| 475 | .query<Member, [string]>("SELECT * FROM member WHERE person_id = ?1") | |
| 476 | .get(personId); | |
| 477 | ||
| 478 | /** Somebody the forge named. A guest has no id; everyone else does. */ | |
| 479 | type Signed = Extract<Who, { role: "user" | "admin" }>; | |
| 480 | ||
| 481 | /** | |
| 482 | * Who may change the roster: an admin, nobody else. A guest is told to sign | |
| 483 | * in, a signed-in person is told no. It hands back the admin it allowed, so | |
| 484 | * the caller stamping `added_by` has an identity the types agree is there | |
| 485 | * rather than one it assumes. | |
| 486 | */ | |
| 487 | const rosterWrite = ( | |
| 488 | who: Who, | |
| 489 | ): | |
| 490 | | { admin: Signed; refused?: undefined } | |
| 491 | | { admin?: undefined; refused: Result<never> } => | |
| 492 | who.role === "admin" | |
| 493 | ? { admin: who } | |
| 494 | : { | |
| 495 | refused: | |
| 496 | who.role === "anon" | |
| 497 | ? fail<never>("signin_required", "Sign in to change the roster.") | |
| 498 | : fail<never>("forbidden", "Only an admin changes the roster."), | |
| 499 | }; | |
| 500 | ||
| 501 | export function listMembers(db: Database, _who: Who): Result<Member[]> { | |
| 502 | return ok( | |
| 503 | db | |
| 504 | .query< | |
| 505 | Member, | |
| 506 | [] | |
| 507 | >("SELECT * FROM member ORDER BY name COLLATE NOCASE, person_id") | |
| 508 | .all(), | |
| 509 | ); | |
| 510 | } | |
| 511 | ||
| 512 | export function addMember( | |
| 513 | db: Database, | |
| 514 | who: Who, | |
| 515 | input: { personId: string; name: string }, | |
| 516 | ): Result<Member> { | |
| 517 | const { admin, refused } = rosterWrite(who); | |
| 518 | if (admin === undefined) return refused; | |
| 519 | const personId = String(input.personId ?? "").trim(); | |
| 520 | if (personId.length === 0 || personId.length > PERSON_ID_MAX) | |
| 521 | return fail("invalid", `A person id is 1 to ${PERSON_ID_MAX} characters.`); | |
| 522 | if (!PERSON_ID.test(personId)) | |
| 523 | return fail("invalid", "A person id is letters, digits, and _ . - only."); | |
| 524 | const name = String(input.name ?? "").trim(); | |
| 525 | if (name.length === 0 || name.length > MEMBER_NAME_MAX) | |
| 526 | return fail("invalid", `A name is 1 to ${MEMBER_NAME_MAX} characters.`); | |
| 527 | ||
| 528 | // Counting and inserting are one thing, or the cap is a suggestion: the | |
| 529 | // terminal is a second process on this file, and two admins adding at once | |
| 530 | // would each count 255 and each insert. Looking the person up belongs in | |
| 531 | // the same transaction for the same reason. | |
| 532 | const done = db.transaction((): Result<Member> & { fresh?: boolean } => { | |
| 533 | // Already here is not a mistake, and not a second row: the same person | |
| 534 | // comes back, and nothing is emitted, because nothing changed. | |
| 535 | const existing = memberRow(db, personId); | |
| 536 | if (existing !== null) return { ...ok(existing), fresh: false }; | |
| 537 | const count = db | |
| 538 | .query<{ n: number }, []>("SELECT count(*) AS n FROM member") | |
| 539 | .get()!.n; | |
| 540 | if (count >= ROSTER_MAX) | |
| 541 | return fail( | |
| 542 | "roster_full", | |
| 543 | `The roster holds ${ROSTER_MAX} people; remove someone to add someone.`, | |
| 544 | ); | |
| 545 | const row = db | |
| 546 | .query< | |
| 547 | Member, | |
| 548 | [string, string, string] | |
| 549 | >("INSERT INTO member (person_id, name, added_by) VALUES (?1, ?2, ?3) RETURNING *") | |
| 550 | .get(personId, name, admin.id)!; | |
| 551 | return { ...ok(row), fresh: true }; | |
| 552 | })(); | |
| 553 | // Emitting waits for the commit: a listener that reads on the event must | |
| 554 | // find the row. Only a row written this call is news. | |
| 555 | const { fresh, ...result } = done; | |
| 556 | if (fresh === true && result.ok) emit("member.added", result.value); | |
| 557 | return result; | |
| 558 | } | |
| 559 | ||
| 560 | export function removeMember( | |
| 561 | db: Database, | |
| 562 | who: Who, | |
| 563 | input: { personId: string }, | |
| 564 | ): Result<Member> { | |
| 565 | const { admin, refused } = rosterWrite(who); | |
| 566 | if (admin === undefined) return refused; | |
| 567 | const personId = String(input.personId ?? "").trim(); | |
| 568 | if (personId.length === 0 || personId.length > PERSON_ID_MAX) | |
| 569 | return fail("invalid", `A person id is 1 to ${PERSON_ID_MAX} characters.`); | |
| 570 | const removed = db | |
| 571 | .query< | |
| 572 | Member, | |
| 573 | [string] | |
| 574 | >("DELETE FROM member WHERE person_id = ?1 RETURNING *") | |
| 575 | .get(personId); | |
| 576 | if (removed === null) | |
| 577 | return fail("not_found", "Nobody by that id is on the roster."); | |
| 578 | emit("member.removed", { person_id: removed.person_id, id: removed.id }); | |
| 579 | return ok(removed); | |
| 580 | } | |
| 581 | ||
| 582 | /** | |
| 583 | * Whether the person asking counts toward a quorum. Anon never does; a | |
| 584 | * signed-in person does only if an admin put them on the roster — being an | |
| 585 | * admin is a permission, not a membership. | |
| 586 | */ | |
| 587 | export const isMember = (db: Database, who: Who): boolean => | |
| 588 | who.role !== "anon" && memberRow(db, who.id) !== null; | |
| 589 | ||
| 445 | 590 | /** What anyone may know about this process: nothing about anybody's data. */ |
| 446 | 591 | export interface Health { |
| 447 | 592 | readonly ok: boolean; |
src/db.test.ts +82
| @@ -44,6 +44,88 @@ test("a database that stopped at the note migrates forward", () => { | ||
| 44 | 44 | expect(() => db.query("SELECT 1 FROM proposal").get()).not.toThrow(); |
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | test("a database with proposals in it takes the newest migration", () => { | |
| 48 | // The path a preview takes: production's file, one version behind, with | |
| 49 | // rows in it. A migration that only ever ran on an empty file is a boot | |
| 50 | // failure waiting for the copy. | |
| 51 | const dir = mkdtempSync(join(tmpdir(), "app-db-")); | |
| 52 | const old = new Database(join(dir, "app.db")); | |
| 53 | old.exec("PRAGMA foreign_keys = ON"); | |
| 54 | old.exec( | |
| 55 | "CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))", | |
| 56 | ); | |
| 57 | const previous = migrations.length - 1; | |
| 58 | migrations.slice(0, previous).forEach((sql, i) => { | |
| 59 | old.exec(sql); | |
| 60 | old.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); | |
| 61 | }); | |
| 62 | old.query( | |
| 63 | "INSERT INTO proposal (title, body, author_id, author_name) VALUES ('Where do we meet', 'One room for the retrospective.', 'u1', 'alice')", | |
| 64 | ).run(); | |
| 65 | old.query( | |
| 66 | "INSERT INTO option (proposal_id, label, position) VALUES (1, 'The kitchen', 0)", | |
| 67 | ).run(); | |
| 68 | old.close(); | |
| 69 | ||
| 70 | const db = new Database(join(dir, "app.db")); | |
| 71 | db.exec("PRAGMA foreign_keys = ON"); | |
| 72 | // The roster arrives with the newest migration and not before it: if | |
| 73 | // `member` were inserted among the ones production has already recorded, | |
| 74 | // this table would be here already and production would never run it. | |
| 75 | expect( | |
| 76 | db | |
| 77 | .query< | |
| 78 | { name: string }, | |
| 79 | [] | |
| 80 | >("SELECT name FROM sqlite_master WHERE name = 'member'") | |
| 81 | .get(), | |
| 82 | ).toBeNull(); | |
| 83 | expect(migrate(db)).toBe(migrations.length); | |
| 84 | expect(check(db)).toEqual({ version: migrations.length, integrity: "ok" }); | |
| 85 | // The rows that were there are the rows that are there: no table was | |
| 86 | // recreated under them. | |
| 87 | expect( | |
| 88 | db | |
| 89 | .query< | |
| 90 | { title: string; author_id: string; state: string }, | |
| 91 | [] | |
| 92 | >("SELECT title, author_id, state FROM proposal WHERE id = 1") | |
| 93 | .get(), | |
| 94 | ).toEqual({ | |
| 95 | title: "Where do we meet", | |
| 96 | author_id: "u1", | |
| 97 | state: "open", | |
| 98 | }); | |
| 99 | expect( | |
| 100 | db | |
| 101 | .query< | |
| 102 | { label: string; proposal_id: number }, | |
| 103 | [] | |
| 104 | >("SELECT label, proposal_id FROM option WHERE id = 1") | |
| 105 | .get(), | |
| 106 | ).toEqual({ label: "The kitchen", proposal_id: 1 }); | |
| 107 | // And the roster the newest migration brings is there and empty. | |
| 108 | expect( | |
| 109 | db.query<{ n: number }, []>("SELECT count(*) AS n FROM member").get()?.n, | |
| 110 | ).toBe(0); | |
| 111 | expect(() => | |
| 112 | db | |
| 113 | .query( | |
| 114 | "INSERT INTO member (person_id, name, added_by) VALUES ('u1', 'alice', 'u0')", | |
| 115 | ) | |
| 116 | .run(), | |
| 117 | ).not.toThrow(); | |
| 118 | // One row per person: the id is the person, whatever they are called. | |
| 119 | expect(() => | |
| 120 | db | |
| 121 | .query( | |
| 122 | "INSERT INTO member (person_id, name, added_by) VALUES ('u1', 'alice again', 'u0')", | |
| 123 | ) | |
| 124 | .run(), | |
| 125 | ).toThrow(); | |
| 126 | db.close(); | |
| 127 | }); | |
| 128 | ||
| 47 | 129 | test("a proposal has options that go with it, and the template's note is gone", () => { |
| 48 | 130 | const db = openDb(mkdtempSync(join(tmpdir(), "app-db-"))); |
| 49 | 131 | const names = db |
src/db.ts +12
| @@ -74,6 +74,18 @@ export const migrations: string[] = [ | ||
| 74 | 74 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 75 | 75 | ); |
| 76 | 76 | CREATE INDEX remark_proposal ON remark (proposal_id)`, |
| 77 | // The roster: who counts toward a quorum. A member is the forge's | |
| 78 | // person_id, once — the name beside it is only what to show, so a rename | |
| 79 | // never drops anybody. `added_by` is the admin who put them there, kept | |
| 80 | // as a plain id because that person may be gone by the time it is read. | |
| 81 | // A new table beside the old ones: nothing existing is touched. | |
| 82 | `CREATE TABLE member ( | |
| 83 | id INTEGER PRIMARY KEY, | |
| 84 | person_id TEXT NOT NULL UNIQUE CHECK (length(person_id) BETWEEN 1 AND 64), | |
| 85 | name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64), | |
| 86 | added_by TEXT NOT NULL, | |
| 87 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 88 | )`, | |
| 77 | 89 | ]; |
| 78 | 90 | |
| 79 | 91 | export function openDb(dir = dataDir): Database { |
src/routes.ts +1
| @@ -59,6 +59,7 @@ const STATUS: Record<Failure, number> = { | ||
| 59 | 59 | forbidden: 403, |
| 60 | 60 | not_found: 404, |
| 61 | 61 | invalid: 422, |
| 62 | roster_full: 409, | |
| 62 | 63 | }; |
| 63 | 64 | |
| 64 | 65 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |