Public: anyone can read this; the crew can change it.

The roster, part one: the member table and the three actions #42

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/quorum:ledger/27-the-roster-part-one-the-member-table-and → main 3 commits fork

  • Merged. ledger/27-the-roster-part-one-the-member-table-and was merged into main as 1020c9d.

Review

Sign in to review this change.
5 files changed +376 −2
src/actions.test.ts +135 −1
@@ -4,20 +4,27 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import {
66 acceptCounter,
7 addMember,
78 addRemark,
89 canDeleteRemark,
910 canWithdraw,
1011 deleteRemark,
1112 getProposal,
1213 health,
14 isMember,
15 listMembers,
1316 listProposals,
1417 listRemarks,
18 MEMBER_NAME_MAX,
19 PERSON_ID_MAX,
1520 propose,
21 removeMember,
22 ROSTER_MAX,
1623 withdrawProposal,
1724 type AdminHealth,
1825 } from "./actions.ts";
1926 import { migrations, openDb } from "./db.ts";
20 import { useDb } from "./live.ts";
27 import { subscribe, useDb, type LiveEvent } from "./live.ts";
2128 import type { Rule } from "./quorum.ts";
2229 import type { Who } from "./who.ts";
2330
@@ -580,3 +587,130 @@ test("a schema behind the migration list is not ok", () => {
580587 expect(r.value.schemaVersion).toBe(migrations.length - 1);
581588 behind.close();
582589 });
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 {
9191 readonly created_at: string;
9292 }
9393
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";
95100 export type Result<T> =
96101 | { ok: true; value: T }
97102 | { ok: false; error: Failure; message: string };
@@ -442,6 +447,146 @@ const exists = (db: Database, id: number): boolean =>
442447 .query<{ id: number }, [number]>("SELECT id FROM proposal WHERE id = ?1")
443448 .get(id) !== null;
444449
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
445590 /** What anyone may know about this process: nothing about anybody's data. */
446591 export interface Health {
447592 readonly ok: boolean;
src/db.test.ts +82
@@ -44,6 +44,88 @@ test("a database that stopped at the note migrates forward", () => {
4444 expect(() => db.query("SELECT 1 FROM proposal").get()).not.toThrow();
4545 });
4646
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
47129 test("a proposal has options that go with it, and the template's note is gone", () => {
48130 const db = openDb(mkdtempSync(join(tmpdir(), "app-db-")));
49131 const names = db
src/db.ts +12
@@ -74,6 +74,18 @@ export const migrations: string[] = [
7474 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
7575 );
7676 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 )`,
7789 ];
7890
7991 export function openDb(dir = dataDir): Database {
src/routes.ts +1
@@ -59,6 +59,7 @@ const STATUS: Record<Failure, number> = {
5959 forbidden: 403,
6060 not_found: 404,
6161 invalid: 422,
62 roster_full: 409,
6263 };
6364
6465 /** A result as JSON: the value, or `{ error, message }` with the matching status. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs