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

Merged opened this

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

Merged

scout/forge:scout/2583-audienceof-lands-from-pull-2571-s-branch → main 2 commits fork

  • Merged. scout/2583-audienceof-lands-from-pull-2571-s-branch was merged into main as 363d3ce.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs