Public: anyone can read this; the crew can change it.
audienceOf lands from pull #2571's branch, with a signed-out wording and the crew named in the public sentence #2586
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2583-audienceof-lands-from-pull-2571-s-branch → main 2 commits fork
2 files
changed +459
−0
src/core/audience.ts added +214
| @@ -0,0 +1,214 @@ | ||
| 1 | /** | |
| 2 | * Who can see a repository or an app, in one sentence. | |
| 3 | * | |
| 4 | * `audienceOf` is pure: plain facts in, a level and a sentence out, so every | |
| 5 | * wording is a test with no database. `audienceFacts` reads the facts, and | |
| 6 | * asks the same two rules the forge enforces — `REPO_VISIBLE` and | |
| 7 | * `REPO_WRITABLE` — once per candidate, so the sentence cannot drift from | |
| 8 | * what the forge lets through. Site admins are not counted: their reach is | |
| 9 | * the forge's, not the repository's. Nor are suspended or removed accounts. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import type { Repo } from "./repos.ts"; | |
| 14 | import type { User } from "./users.ts"; | |
| 15 | import { REPO_VISIBLE, REPO_WRITABLE } from "./visibility.ts"; | |
| 16 | ||
| 17 | export interface AudienceFacts { | |
| 18 | readonly visibility: "public" | "private"; | |
| 19 | /** Who is looking: nobody signed in, a person, or a site admin. */ | |
| 20 | readonly viewer: "signed-out" | "person" | "admin"; | |
| 21 | readonly ownerKind: "user" | "org"; | |
| 22 | readonly ownerName: string; | |
| 23 | /** People (not the crew) other than the viewer who may read it. */ | |
| 24 | readonly readers: number; | |
| 25 | /** People (not the crew) who may change it, the viewer among them. */ | |
| 26 | readonly writers: number; | |
| 27 | readonly viewerWrites: boolean; | |
| 28 | /** Whether any of the crew works here. */ | |
| 29 | readonly crew: boolean; | |
| 30 | /** | |
| 31 | * The app's access, when the repository has an app. People are counted | |
| 32 | * once, never the viewer: `writers` may change the repository, `named` are | |
| 33 | * named by the app and cannot. | |
| 34 | */ | |
| 35 | readonly app?: { | |
| 36 | readonly access: "public" | "private"; | |
| 37 | readonly writers: number; | |
| 38 | readonly named: number; | |
| 39 | readonly viewerNamed: boolean; | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | export interface Audience { | |
| 44 | /** `split`: public code, private app. */ | |
| 45 | readonly level: "public" | "private" | "split"; | |
| 46 | readonly sentence: string; | |
| 47 | } | |
| 48 | ||
| 49 | const people = (n: number): string => (n === 1 ? "1 person" : `${n} people`); | |
| 50 | ||
| 51 | /** "a", "a and b", "a, b, and c". */ | |
| 52 | function list(parts: readonly string[]): string { | |
| 53 | if (parts.length <= 2) return parts.join(" and "); | |
| 54 | return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`; | |
| 55 | } | |
| 56 | ||
| 57 | export function audienceOf(facts: AudienceFacts): Audience { | |
| 58 | const crew = facts.crew ? ["the crew"] : []; | |
| 59 | // "you" is only said to someone signed in. | |
| 60 | const you = facts.viewer === "signed-out" ? [] : ["you"]; | |
| 61 | if (facts.visibility === "public") { | |
| 62 | if (facts.app?.access === "private") { | |
| 63 | // Only someone who can change it, or is named, is told "you". | |
| 64 | const youToo = | |
| 65 | you.length > 0 && (facts.viewerWrites || facts.app.viewerNamed) | |
| 66 | ? you | |
| 67 | : []; | |
| 68 | const parts = [ | |
| 69 | ...youToo, | |
| 70 | ...(facts.app.writers > 0 | |
| 71 | ? [`${people(facts.app.writers)} who can change it`] | |
| 72 | : []), | |
| 73 | ...(facts.app.named > 0 ? [`${people(facts.app.named)} it names`] : []), | |
| 74 | ...crew, | |
| 75 | ]; | |
| 76 | return { | |
| 77 | level: "split", | |
| 78 | sentence: `The code is public; the app answers only to ${ | |
| 79 | parts.length === 0 ? "nobody" : list(parts) | |
| 80 | }.`, | |
| 81 | }; | |
| 82 | } | |
| 83 | const changers = [ | |
| 84 | ...(facts.writers > 0 ? [people(facts.writers)] : []), | |
| 85 | ...crew, | |
| 86 | ]; | |
| 87 | return { | |
| 88 | level: "public", | |
| 89 | sentence: `Public: anyone can read this; ${ | |
| 90 | changers.length === 0 ? "nobody" : list(changers) | |
| 91 | } can change it.`, | |
| 92 | }; | |
| 93 | } | |
| 94 | const parts = [...you]; | |
| 95 | if (facts.readers > 0) { | |
| 96 | parts.push( | |
| 97 | facts.ownerKind === "org" | |
| 98 | ? `${people(facts.readers)} in ${facts.ownerName}` | |
| 99 | : facts.ownerName, | |
| 100 | ); | |
| 101 | } | |
| 102 | const alone = parts.length === 1 && you.length === 1; | |
| 103 | parts.push(...crew); | |
| 104 | return { | |
| 105 | level: "private", | |
| 106 | sentence: `Private: ${alone ? "only " : ""}${ | |
| 107 | parts.length === 0 ? "nobody" : list(parts) | |
| 108 | }.`, | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 112 | /** How many active, non-admin people satisfy `rule` (over `?1` = repo id). */ | |
| 113 | function countPeople( | |
| 114 | db: Database, | |
| 115 | repoId: string, | |
| 116 | rule: string, | |
| 117 | exceptId: string | undefined, | |
| 118 | ): number { | |
| 119 | // The rules take the person as `?1`; here each candidate is that person. | |
| 120 | const asCandidate = rule.replaceAll("?1", "cand.id"); | |
| 121 | return db | |
| 122 | .query<{ n: number }, [string, string]>( | |
| 123 | `SELECT COUNT(*) AS n FROM user AS cand | |
| 124 | WHERE cand.kind = 'user' AND cand.is_agent = 0 AND cand.is_admin = 0 | |
| 125 | AND cand.suspended_at IS NULL AND cand.removed_at IS NULL | |
| 126 | AND cand.id != ?2 | |
| 127 | AND EXISTS (SELECT 1 FROM repo WHERE repo.id = ?1 AND ${asCandidate})`, | |
| 128 | ) | |
| 129 | .get(repoId, exceptId ?? "")!.n; | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * `REPO_WRITABLE` without its site-admin clause: an admin's reach is the | |
| 134 | * forge's, so only membership of the repository itself earns them "you". | |
| 135 | * Throws if the rule is reshaped, so a drift cannot go unnoticed. | |
| 136 | */ | |
| 137 | const MEMBER_WRITABLE = REPO_WRITABLE.replace( | |
| 138 | /\n\s*OR EXISTS \(SELECT 1 FROM user AS me WHERE me\.id = \?1 AND me\.is_admin = 1\)/, | |
| 139 | "", | |
| 140 | ); | |
| 141 | if (MEMBER_WRITABLE === REPO_WRITABLE) { | |
| 142 | throw new Error("audience: REPO_WRITABLE lost its admin clause; update audience.ts"); | |
| 143 | } | |
| 144 | ||
| 145 | export function audienceFacts( | |
| 146 | db: Database, | |
| 147 | repo: Repo, | |
| 148 | viewer: User | undefined, | |
| 149 | ): AudienceFacts { | |
| 150 | const owner = db | |
| 151 | .query<{ kind: "user" | "org" }, [string]>( | |
| 152 | "SELECT kind FROM user WHERE id = ?1", | |
| 153 | ) | |
| 154 | .get(repo.ownerId); | |
| 155 | const viewerId = viewer?.id; | |
| 156 | const writes = | |
| 157 | viewerId !== undefined && | |
| 158 | db | |
| 159 | .query<{ n: number }, [string, string]>( | |
| 160 | `SELECT 1 AS n FROM repo WHERE repo.id = ?1 AND ${MEMBER_WRITABLE.replaceAll("?1", "?2")}`, | |
| 161 | ) | |
| 162 | .get(repo.id, viewerId) !== null; | |
| 163 | const app = db | |
| 164 | .query<{ id: string; access: "public" | "private" }, [string]>( | |
| 165 | "SELECT id, access FROM app WHERE repo_id = ?1", | |
| 166 | ) | |
| 167 | .get(repo.id); | |
| 168 | const namedRule = `EXISTS (SELECT 1 FROM app_member AS m WHERE m.app_id = ?1 AND m.user_id = cand.id)`; | |
| 169 | const activePerson = `cand.kind = 'user' AND cand.is_agent = 0 AND cand.is_admin = 0 | |
| 170 | AND cand.suspended_at IS NULL AND cand.removed_at IS NULL`; | |
| 171 | const named = app | |
| 172 | ? db | |
| 173 | .query<{ n: number }, [string, string, string]>( | |
| 174 | `SELECT COUNT(*) AS n FROM user AS cand | |
| 175 | WHERE ${activePerson} AND cand.id != ?3 AND ${namedRule} | |
| 176 | AND NOT EXISTS (SELECT 1 FROM repo WHERE repo.id = ?2 AND ${REPO_WRITABLE.replaceAll("?1", "cand.id")})`, | |
| 177 | ) | |
| 178 | .get(app.id, repo.id, viewerId ?? "")!.n | |
| 179 | : 0; | |
| 180 | const viewerNamed = | |
| 181 | app !== null && | |
| 182 | viewerId !== undefined && | |
| 183 | db | |
| 184 | .query<{ n: number }, [string, string]>( | |
| 185 | "SELECT 1 AS n FROM app_member WHERE app_id = ?1 AND user_id = ?2", | |
| 186 | ) | |
| 187 | .get(app.id, viewerId) !== null; | |
| 188 | return { | |
| 189 | visibility: repo.visibility, | |
| 190 | viewer: | |
| 191 | viewer === undefined ? "signed-out" : viewer.isAdmin ? "admin" : "person", | |
| 192 | ownerKind: owner?.kind ?? "user", | |
| 193 | ownerName: repo.ownerName, | |
| 194 | readers: countPeople(db, repo.id, REPO_VISIBLE, viewerId), | |
| 195 | writers: countPeople(db, repo.id, REPO_WRITABLE, undefined), | |
| 196 | viewerWrites: writes, | |
| 197 | crew: | |
| 198 | db | |
| 199 | .query<{ n: number }, [string]>( | |
| 200 | "SELECT 1 AS n FROM repo_crew WHERE repo_id = ?1", | |
| 201 | ) | |
| 202 | .get(repo.id) !== null, | |
| 203 | ...(app | |
| 204 | ? { | |
| 205 | app: { | |
| 206 | access: app.access, | |
| 207 | writers: countPeople(db, repo.id, REPO_WRITABLE, viewerId), | |
| 208 | named, | |
| 209 | viewerNamed, | |
| 210 | }, | |
| 211 | } | |
| 212 | : {}), | |
| 213 | }; | |
| 214 | } |
test/audience.test.ts added +245
| @@ -0,0 +1,245 @@ | ||
| 1 | /** | |
| 2 | * The audience sentence: each wording from plain facts, and the counts read | |
| 3 | * from a seeded database against what `REPO_VISIBLE` and `REPO_WRITABLE` let | |
| 4 | * through. | |
| 5 | */ | |
| 6 | import { beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { | |
| 9 | audienceFacts, | |
| 10 | audienceOf, | |
| 11 | type AudienceFacts, | |
| 12 | } from "../src/core/audience.ts"; | |
| 13 | import { getRepoById } from "../src/core/repos.ts"; | |
| 14 | import { getUserById } from "../src/core/users.ts"; | |
| 15 | import { migrations } from "../src/core/schema.ts"; | |
| 16 | import { REPO_VISIBLE, REPO_WRITABLE } from "../src/core/visibility.ts"; | |
| 17 | import { openDb } from "../src/kernel/db.ts"; | |
| 18 | ||
| 19 | const base: AudienceFacts = { | |
| 20 | visibility: "private", | |
| 21 | viewer: "person", | |
| 22 | ownerKind: "user", | |
| 23 | ownerName: "ann", | |
| 24 | readers: 0, | |
| 25 | writers: 1, | |
| 26 | viewerWrites: true, | |
| 27 | crew: true, | |
| 28 | }; | |
| 29 | ||
| 30 | describe("audienceOf", () => { | |
| 31 | test("a private repository of one person", () => { | |
| 32 | expect(audienceOf(base)).toEqual({ | |
| 33 | level: "private", | |
| 34 | sentence: "Private: only you and the crew.", | |
| 35 | }); | |
| 36 | expect(audienceOf({ ...base, crew: false }).sentence).toBe( | |
| 37 | "Private: only you.", | |
| 38 | ); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a private organisation's people", () => { | |
| 42 | const facts = { ...base, ownerKind: "org" as const, ownerName: "acme" }; | |
| 43 | expect(audienceOf({ ...facts, readers: 3 }).sentence).toBe( | |
| 44 | "Private: you, 3 people in acme, and the crew.", | |
| 45 | ); | |
| 46 | expect(audienceOf({ ...facts, readers: 1, crew: false }).sentence).toBe( | |
| 47 | "Private: you and 1 person in acme.", | |
| 48 | ); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a private repository seen by someone who is not its owner", () => { | |
| 52 | expect(audienceOf({ ...base, readers: 1 }).sentence).toBe( | |
| 53 | "Private: you, ann, and the crew.", | |
| 54 | ); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a public repository", () => { | |
| 58 | expect(audienceOf({ ...base, visibility: "public", writers: 2 })).toEqual({ | |
| 59 | level: "public", | |
| 60 | sentence: | |
| 61 | "Public: anyone can read this; 2 people and the crew can change it.", | |
| 62 | }); | |
| 63 | expect( | |
| 64 | audienceOf({ ...base, visibility: "public", writers: 1 }).sentence, | |
| 65 | ).toContain("1 person and the crew can change it"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a signed-out viewer is never called you", () => { | |
| 69 | const out = { ...base, viewer: "signed-out" as const, viewerWrites: false }; | |
| 70 | expect( | |
| 71 | audienceOf({ | |
| 72 | ...out, | |
| 73 | visibility: "public", | |
| 74 | writers: 2, | |
| 75 | app: { access: "private", writers: 2, named: 1, viewerNamed: false }, | |
| 76 | }).sentence, | |
| 77 | ).toBe( | |
| 78 | "The code is public; the app answers only to 2 people who can change it, 1 person it names, and the crew.", | |
| 79 | ); | |
| 80 | expect(audienceOf({ ...out, readers: 1 }).sentence).toBe( | |
| 81 | "Private: ann and the crew.", | |
| 82 | ); | |
| 83 | expect(audienceOf({ ...out, crew: false }).sentence).toBe("Private: nobody."); | |
| 84 | expect( | |
| 85 | audienceOf({ ...out, visibility: "public", writers: 0, crew: false }).sentence, | |
| 86 | ).toBe("Public: anyone can read this; nobody can change it."); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a private app on a public repository", () => { | |
| 90 | const facts: AudienceFacts = { | |
| 91 | ...base, | |
| 92 | visibility: "public", | |
| 93 | writers: 3, | |
| 94 | app: { access: "private", writers: 2, named: 2, viewerNamed: false }, | |
| 95 | }; | |
| 96 | expect(audienceOf(facts)).toEqual({ | |
| 97 | level: "split", | |
| 98 | sentence: | |
| 99 | "The code is public; the app answers only to you, 2 people who can change it, 2 people it names, and the crew.", | |
| 100 | }); | |
| 101 | expect( | |
| 102 | audienceOf({ ...facts, writers: 1, crew: false, app: { access: "private", writers: 0, named: 0, viewerNamed: false } }) | |
| 103 | .sentence, | |
| 104 | ).toBe("The code is public; the app answers only to you."); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a public app on a public repository changes nothing", () => { | |
| 108 | const facts = { ...base, visibility: "public" as const }; | |
| 109 | expect(audienceOf({ ...facts, app: { access: "public", writers: 0, named: 0, viewerNamed: false } }).level).toBe( | |
| 110 | "public", | |
| 111 | ); | |
| 112 | }); | |
| 113 | }); | |
| 114 | ||
| 115 | describe("audienceFacts", () => { | |
| 116 | let db: Database; | |
| 117 | const ins = (sql: string) => db.run(sql); | |
| 118 | const user = (id: string) => | |
| 119 | ins( | |
| 120 | `INSERT INTO user (id, username, email, password_hash) VALUES ('${id}','${id}','${id}@x','x')`, | |
| 121 | ); | |
| 122 | const facts = (repoId: string, viewerId: string | undefined) => | |
| 123 | audienceFacts( | |
| 124 | db, | |
| 125 | getRepoById(db, repoId)!, | |
| 126 | viewerId ? getUserById(db, viewerId) : undefined, | |
| 127 | ); | |
| 128 | ||
| 129 | beforeAll(() => { | |
| 130 | db = openDb(":memory:", migrations); | |
| 131 | ins("INSERT INTO user (id, username, email, password_hash, kind) VALUES ('acme','acme','a@x','x','org')"); | |
| 132 | for (const id of ["ann", "bob", "cy", "dee", "gone", "root"]) user(id); | |
| 133 | ins("UPDATE user SET suspended_at = '2026-01-01T00:00:00Z' WHERE id = 'gone'"); | |
| 134 | ins("UPDATE user SET is_admin = 1 WHERE id = 'root'"); | |
| 135 | ins("INSERT INTO user (id, username, email, password_hash, is_agent) VALUES ('bot','bot','b@x','x',1)"); | |
| 136 | ins("INSERT INTO team (id, org_id, name, permission) VALUES ('t1','acme','read','read'), ('t2','acme','dev','write')"); | |
| 137 | ins("INSERT INTO team_member (team_id, user_id) VALUES ('t1','bob'),('t1','cy'),('t2','cy'),('t2','dee'),('t1','gone'),('t2','gone')"); | |
| 138 | ins("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('org-priv','acme','p','private'),('org-pub','acme','q','public'),('mine','ann','m','private')"); | |
| 139 | ins("INSERT INTO repo_crew (repo_id, user_id) VALUES ('org-priv','bot')"); | |
| 140 | ins("INSERT INTO app (id, repo_id, slug, access) VALUES ('a1','org-pub','pub-app','private')"); | |
| 141 | ins("INSERT INTO app_member (app_id, user_id) VALUES ('a1','ann'),('a1','gone'),('a1','cy')"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("counts match what visibility.ts lets through", () => { | |
| 145 | const every = (rule: string, repoId: string) => | |
| 146 | db | |
| 147 | .query<{ id: string }, [string]>( | |
| 148 | `SELECT cand.id FROM user AS cand WHERE cand.kind = 'user' AND cand.is_agent = 0 | |
| 149 | AND cand.is_admin = 0 AND cand.suspended_at IS NULL | |
| 150 | AND EXISTS (SELECT 1 FROM repo WHERE repo.id = ?1 AND ${rule.replaceAll("?1", "cand.id")})`, | |
| 151 | ) | |
| 152 | .all(repoId) | |
| 153 | .map((r) => r.id); | |
| 154 | const readers = every(REPO_VISIBLE, "org-priv"); | |
| 155 | expect(readers.sort()).toEqual(["bob", "cy", "dee"]); | |
| 156 | const f = facts("org-priv", "ann"); | |
| 157 | expect(f.readers).toBe(readers.length); | |
| 158 | expect(f.writers).toBe(every(REPO_WRITABLE, "org-priv").length); | |
| 159 | expect(f.writers).toBe(2); | |
| 160 | expect(f.crew).toBe(true); | |
| 161 | expect(f.ownerKind).toBe("org"); | |
| 162 | }); | |
| 163 | ||
| 164 | test("the viewer is not counted among the readers, and may write", () => { | |
| 165 | const f = facts("org-priv", "cy"); | |
| 166 | expect(f.readers).toBe(2); | |
| 167 | expect(f.viewerWrites).toBe(true); | |
| 168 | expect(facts("org-priv", "bob").viewerWrites).toBe(false); | |
| 169 | expect(audienceOf(facts("org-priv", "cy")).sentence).toBe( | |
| 170 | "Private: you, 2 people in acme, and the crew.", | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a personal repository has no one but its owner", () => { | |
| 175 | expect(audienceOf(facts("mine", "ann"))).toEqual({ | |
| 176 | level: "private", | |
| 177 | sentence: "Private: only you.", | |
| 178 | }); | |
| 179 | expect(facts("mine", "ann").writers).toBe(1); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a private app names its active people", () => { | |
| 183 | const f = facts("org-pub", "dee"); | |
| 184 | expect(f.app).toEqual({ | |
| 185 | access: "private", | |
| 186 | writers: 1, | |
| 187 | named: 1, | |
| 188 | viewerNamed: false, | |
| 189 | }); | |
| 190 | expect(audienceOf(f).sentence).toBe( | |
| 191 | "The code is public; the app answers only to you, 1 person who can change it, and 1 person it names.", | |
| 192 | ); | |
| 193 | }); | |
| 194 | ||
| 195 | test("a writer who is also named is counted once", () => { | |
| 196 | // cy writes and is named; ann is only named; dee only writes. | |
| 197 | const f = facts("org-pub", undefined); | |
| 198 | expect(f.app).toMatchObject({ writers: 2, named: 1 }); | |
| 199 | expect(audienceOf(f).sentence).toBe( | |
| 200 | "The code is public; the app answers only to 2 people who can change it and 1 person it names.", | |
| 201 | ); | |
| 202 | const asCy = facts("org-pub", "cy"); | |
| 203 | expect(asCy.app).toMatchObject({ writers: 1, named: 1, viewerNamed: true }); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a named viewer who cannot write is 'you', and not counted again", () => { | |
| 207 | const f = facts("org-pub", "ann"); | |
| 208 | expect(f.viewerWrites).toBe(false); | |
| 209 | expect(f.app).toMatchObject({ writers: 2, named: 0, viewerNamed: true }); | |
| 210 | expect(audienceOf(f).sentence).toBe( | |
| 211 | "The code is public; the app answers only to you and 2 people who can change it.", | |
| 212 | ); | |
| 213 | }); | |
| 214 | ||
| 215 | test("a viewer who neither writes nor is named is not told 'you'", () => { | |
| 216 | for (const id of ["bob", "root"]) { | |
| 217 | const f = facts("org-pub", id); | |
| 218 | expect(audienceOf(f).sentence).not.toContain("you"); | |
| 219 | } | |
| 220 | expect(audienceOf(facts("org-pub", "bob")).sentence).toBe( | |
| 221 | "The code is public; the app answers only to 2 people who can change it and 1 person it names.", | |
| 222 | ); | |
| 223 | }); | |
| 224 | ||
| 225 | test("an admin viewer is told 'you' and counted like a person", () => { | |
| 226 | const f = facts("org-priv", "root"); | |
| 227 | expect(f.viewer).toBe("admin"); | |
| 228 | // Their reach is the forge's: membership of the repository is what counts. | |
| 229 | expect(f.viewerWrites).toBe(false); | |
| 230 | expect(f.readers).toBe(3); | |
| 231 | expect(audienceOf(f).sentence).toBe( | |
| 232 | "Private: you, 3 people in acme, and the crew.", | |
| 233 | ); | |
| 234 | }); | |
| 235 | ||
| 236 | test("an anonymous viewer is no one", () => { | |
| 237 | const f = facts("org-pub", undefined); | |
| 238 | expect(f.viewer).toBe("signed-out"); | |
| 239 | expect(audienceOf(f).sentence).toBe( | |
| 240 | "The code is public; the app answers only to 2 people who can change it and 1 person it names.", | |
| 241 | ); | |
| 242 | expect(f.viewerWrites).toBe(false); | |
| 243 | expect(f.writers).toBe(2); | |
| 244 | }); | |
| 245 | }); |
new above
offline