Public: anyone can read this; the crew can change it.
Private by default is plain: every repository and app page says in one line who can see it, and the API gives the same line #10394
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10391-private-by-default-is-plain-every-reposi → main 3 commits fork 1 thread · 1 unresolved
9 files
changed +306
−8
docs/TOUR.md +5 −2
| @@ -287,8 +287,11 @@ never, a private app never. Every door reads `frontJson` in | ||
| 287 | 287 | Every repository page says, beside the Private/Public badge, who can see it in |
| 288 | 288 | one sentence ("Private: only you and the crew."), and a writer's click goes to |
| 289 | 289 | Settings. `GET /api/v1/repos/:owner/:name` returns the same words as |
| 290 | `audience: { level, sentence }`, the MCP `repo` tool carries it too, and the | |
| 291 | terminal's About card (`I`) shows it. It gives counts, never names; site | |
| 290 | `audience: { level, who, sentence }` (`who` is `owner`, `members` or | |
| 291 | `anyone`), and so does a `PATCH` that changes its visibility, in the same | |
| 292 | answer. The MCP `repo` and `here` tools carry it too (`here` on a repository | |
| 293 | page, and on an app's link only for someone who may read the repository), and | |
| 294 | the terminal's About card (`I`) shows the sentence (`docs/features/visibility-line.md`). It gives counts, never names; site | |
| 292 | 295 | admins are not counted. All of them read `audienceFor` in `src/core/audience.ts`. |
| 293 | 296 | |
| 294 | 297 | ## Archive |
docs/features/visibility-line.md added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | # Who can see it, in one line | |
| 2 | ||
| 3 | Every repository starts private: a new repository, a new app and its | |
| 4 | repository, a fork of a private repository (whatever the fork asks for) and the | |
| 5 | preview of a private app's pull. Each surface then says who can see it in the | |
| 6 | same words, all read from `audienceFor` in `src/core/audience.ts`: | |
| 7 | ||
| 8 | json | |
| 9 | "audience": { "level": "private", "who": "owner", "sentence": "Private: only you and the crew." } | |
| 10 | ||
| 11 | ||
| 12 | - **`who`** is one word a client can branch on: `owner` (a person's private | |
| 13 | repository: its owner and their crew), `members` (an organization's private | |
| 14 | repository) or `anyone` (public). The `sentence` says the same for a person. | |
| 15 | It is named `audience`, not `visibility`, because `visibility` is already the | |
| 16 | `"public" | "private"` string clients read. | |
| 17 | - **Web**: the repository head shows the sentence beside the Private/Public badge. | |
| 18 | - **API**: `GET /api/v1/repos/:owner/:name` carries it, and a `PATCH` that | |
| 19 | changes `visibility` returns the new line in the same answer (the change is | |
| 20 | receipted as `repo.settings_updated`). | |
| 21 | - **MCP**: the `repo` tool carries it; `here` carries it on a repository page | |
| 22 | and on an app's link. On an app's link it is there only for someone who may | |
| 23 | read the repository: a public app can sit on a private repository, and its | |
| 24 | visitors are told nothing about that repository, nor its counts. | |
| 25 | - **Terminal**: the About card (`I`) prints the sentence. `who` is not printed | |
| 26 | on its own there: the sentence already opens with it, and the terminal is | |
| 27 | read by people, not branched on. | |
| 28 | ||
| 29 | A stranger asking about a private repository gets exactly the answer a missing | |
| 30 | one gives, on the API and through `here`. | |
| 31 | ||
| 32 | Tried by `test/visibility-line.test.ts`. |
src/actions/here.ts +4
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * An action that needs an account is listed for someone without one, |
| 13 | 13 | * saying so, because that is the next thing they need to know. |
| 14 | 14 | */ |
| 15 | import { audienceFor } from "../core/audience.ts"; | |
| 15 | 16 | import { lacks } from "../core/roles.ts"; |
| 16 | 17 | import { activeEdgeToken } from "../http/edge.ts"; |
| 17 | 18 | import { sshTellCommand } from "../ssh/command.ts"; |
| @@ -65,6 +66,8 @@ export interface Manifest { | ||
| 65 | 66 | readonly kind: "repository"; |
| 66 | 67 | readonly name: string; |
| 67 | 68 | readonly visibility: "public" | "private"; |
| 69 | /** Who can see it, in the words its page and `GET` say (`core/audience.ts`). */ | |
| 70 | readonly audience: ReturnType<typeof audienceFor>; | |
| 68 | 71 | readonly app?: { |
| 69 | 72 | readonly access: "public" | "private"; |
| 70 | 73 | readonly state?: string; |
| @@ -242,6 +245,7 @@ function repoHere( | ||
| 242 | 245 | kind: "repository" as const, |
| 243 | 246 | name: `${repo.ownerName}/${repo.name}`, |
| 244 | 247 | visibility: repo.visibility, |
| 248 | audience: audienceFor(app.db, repo, viewer), | |
| 245 | 249 | }; |
| 246 | 250 | // A private app the viewer may not enter is not there, to them. |
| 247 | 251 | if (record === undefined || !canEnterApp(app.db, repo, record, viewer)) |
src/apps/here.ts +9 −1
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | * render from, as plain data. Anyone the door would turn away is refused. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { getRepoById, canEnterApp } from "../core/repos.ts"; | |
| 7 | import { audienceFor } from "../core/audience.ts"; | |
| 8 | import { getRepoById, canEnterApp, canRead } from "../core/repos.ts"; | |
| 8 | 9 | import { mayView } from "../core/sealed.ts"; |
| 9 | 10 | import { getIssue } from "../core/issues.ts"; |
| 10 | 11 | import type { User } from "../core/users.ts"; |
| @@ -15,6 +16,12 @@ import { isHostPort, pillWhere, reportUrl, resolveAppHostname } from "./proxy.ts | ||
| 15 | 16 | export type Here = { |
| 16 | 17 | app: string; |
| 17 | 18 | repo: string | null; |
| 19 | /** | |
| 20 | * Who can see the repository, as its page says it (`core/audience.ts`). | |
| 21 | * Only for someone who may read the repository: a public app can sit on a | |
| 22 | * private one, and its visitors are not told so, nor its counts. | |
| 23 | */ | |
| 24 | audience?: ReturnType<typeof audienceFor>; | |
| 18 | 25 | environment: string; |
| 19 | 26 | pull: { number: number; title: string } | null; |
| 20 | 27 | working: string[]; |
| @@ -49,6 +56,7 @@ export function hereOf(runtime: AppsRuntime, link: string, user: User): Here | u | ||
| 49 | 56 | return { |
| 50 | 57 | app: where.app, |
| 51 | 58 | repo: where.repo ?? null, |
| 59 | ...(canRead(app.db, repo, user) ? { audience: audienceFor(app.db, repo, user) } : {}), | |
| 52 | 60 | environment: target.env, |
| 53 | 61 | pull: |
| 54 | 62 | where.pull !== undefined && where.title !== undefined |
src/core/audience.ts +15 −2
| @@ -46,6 +46,18 @@ export interface Audience { | ||
| 46 | 46 | readonly sentence: string; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | /** | |
| 50 | * Who may read the repository, as one word a client can branch on: its | |
| 51 | * owner (and the owner's crew), the members of the organization that owns | |
| 52 | * it, or anyone. The sentence says the same thing for a person to read. | |
| 53 | */ | |
| 54 | export type Who = "owner" | "members" | "anyone"; | |
| 55 | ||
| 56 | export function whoOf(facts: Pick<AudienceFacts, "visibility" | "ownerKind">): Who { | |
| 57 | if (facts.visibility === "public") return "anyone"; | |
| 58 | return facts.ownerKind === "org" ? "members" : "owner"; | |
| 59 | } | |
| 60 | ||
| 49 | 61 | const people = (n: number): string => (n === 1 ? "1 person" : `${n} people`); |
| 50 | 62 | |
| 51 | 63 | /** "a", "a and b", "a, b, and c". */ |
| @@ -218,6 +230,7 @@ export function audienceFor( | ||
| 218 | 230 | db: Database, |
| 219 | 231 | repo: Repo, |
| 220 | 232 | viewer: User | undefined, |
| 221 | ): Audience { | |
| 222 | return audienceOf(audienceFacts(db, repo, viewer)); | |
| 233 | ): Audience & { readonly who: Who } { | |
| 234 | const facts = audienceFacts(db, repo, viewer); | |
| 235 | return { ...audienceOf(facts), who: whoOf(facts) }; | |
| 223 | 236 | } |
src/http/api.ts +5 −1
| @@ -1665,7 +1665,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1665 | 1665 | const shown = showProvenance(ctx.app, ctx.auth.user, repo, input.showProvenance); |
| 1666 | 1666 | if (!shown.ok) return respondAccess(shown.error, 422); |
| 1667 | 1667 | } |
| 1668 | return json(repoJson(ctx, updated.value)); | |
| 1668 | // The line about who can see it moves with the setting, in this answer. | |
| 1669 | return json({ | |
| 1670 | ...repoJson(ctx, updated.value), | |
| 1671 | audience: audienceFor(ctx.app.db, updated.value, ctx.auth.user), | |
| 1672 | }); | |
| 1669 | 1673 | }); |
| 1670 | 1674 | |
| 1671 | 1675 | router.add("DELETE", "/api/v1/repos/:owner/:name", async (ctx) => { |
src/http/mcp/tools/here.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "here", |
| 8 | 8 | description: |
| 9 | 9 | "What you may do on a forge page; given an app's link, what its /__forge/ door shows.", |
| 10 | doc: "`{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page", | |
| 10 | doc: "`{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, audience: { level, who, sentence } (only when you may read the repository), environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { url: { type: "string", maxLength: 2000 } }, |
src/http/mcp/tools/repo.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | ||
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | name: "repo", |
| 9 | doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name`", | |
| 9 | doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name`", | |
| 10 | 10 | description: |
| 11 | 11 | "One repository you can read, as GET /api/v1/repos/:owner/:name.", |
| 12 | 12 | inputSchema: { |
test/visibility-line.test.ts added +234
| @@ -0,0 +1,234 @@ | ||
| 1 | /** | |
| 2 | * Private by default, and plain about it (#10391): a new repository, a new | |
| 3 | * app, a fork of a private one and the preview of a private app's pull each | |
| 4 | * start private, and the API, the MCP `here` tool and a visibility change all | |
| 5 | * say so in the same words (`audience { level, who, sentence }`). A stranger | |
| 6 | * asking about a private repository gets exactly what a missing one gets. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 14 | import { newApp } from "../src/actions/apps.ts"; | |
| 15 | import { openPull } from "../src/actions/pulls.ts"; | |
| 16 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 17 | import { audienceFor, whoOf } from "../src/core/audience.ts"; | |
| 18 | import { createApp as createAppRecord, getAppByRepo } from "../src/core/apps.ts"; | |
| 19 | import { getRepo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 23 | import { promote } from "./helpers/members.ts"; | |
| 24 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | describe("who can see it, said once, everywhere", () => { | |
| 30 | let dataDir: string; | |
| 31 | let app: App; | |
| 32 | let servers: Servers; | |
| 33 | let runtime: AppsRuntime; | |
| 34 | let alice: User; | |
| 35 | let aliceRw = ""; | |
| 36 | let bobRw = ""; | |
| 37 | let pull = 0; | |
| 38 | let id = 1; | |
| 39 | ||
| 40 | const here = async (token: string, url: string) => { | |
| 41 | const res = await fetch(`${servers.url}/mcp`, { | |
| 42 | method: "POST", | |
| 43 | headers: { | |
| 44 | "content-type": "application/json", | |
| 45 | accept: "application/json, text/event-stream", | |
| 46 | authorization: `Bearer ${token}`, | |
| 47 | }, | |
| 48 | body: JSON.stringify({ | |
| 49 | jsonrpc: "2.0", | |
| 50 | id: id++, | |
| 51 | method: "tools/call", | |
| 52 | params: { name: "here", arguments: { url } }, | |
| 53 | }), | |
| 54 | }); | |
| 55 | const body: any = await res.json(); | |
| 56 | const text: string = body.result.content[0].text; | |
| 57 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 58 | }; | |
| 59 | const api = (path: string, token: string, init: RequestInit = {}) => | |
| 60 | fetch(`${servers.url}/api/v1${path}`, { | |
| 61 | ...init, | |
| 62 | headers: { | |
| 63 | ...(token ? { authorization: `Bearer ${token}` } : {}), | |
| 64 | "content-type": "application/json", | |
| 65 | }, | |
| 66 | }); | |
| 67 | const appHost = (label: string) => `http://${label}.apps.test:${servers.port}/`; | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-visibility-line-")); | |
| 71 | app = createApp({ dataDir, port: 0 }); | |
| 72 | servers = await serve(app); | |
| 73 | runtime = new AppsRuntime(app, { | |
| 74 | domain: "apps.test", | |
| 75 | scheme: "http", | |
| 76 | publicPort: servers.port, | |
| 77 | forgeUrl: servers.url, | |
| 78 | }); | |
| 79 | await runtime.start(); | |
| 80 | await seedAdmin(app, { username: "root", email: "[email protected]", password: "a-good-passphrase" }); | |
| 81 | for (const name of ["alice", "bob"]) { | |
| 82 | const made = await createUser(app.db, { | |
| 83 | origin: "seed", | |
| 84 | trusted: true, | |
| 85 | username: name, | |
| 86 | email: `${name}@forge.local`, | |
| 87 | password: "a-good-passphrase", | |
| 88 | }); | |
| 89 | if (!made.ok) throw new Error(made.error); | |
| 90 | promote(app.db, made.value); | |
| 91 | } | |
| 92 | alice = getUserByName(app.db, "alice")!; | |
| 93 | aliceRw = tokenFor(app, "alice", "admin"); | |
| 94 | bobRw = tokenFor(app, "bob", "rw"); | |
| 95 | }, 30_000); | |
| 96 | ||
| 97 | afterAll(async () => { | |
| 98 | await runtime.stop(); | |
| 99 | servers.stop(); | |
| 100 | app.close(); | |
| 101 | await rm(dataDir, { recursive: true, force: true }); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a new repository is private to its owner, and GET says so", async () => { | |
| 105 | const made = await createRepo(app, alice, { name: "notes" }); | |
| 106 | if (!made.ok) throw new Error(String(made.error)); | |
| 107 | expect(made.value.visibility).toBe("private"); | |
| 108 | const body: any = await (await api("/repos/alice/notes", aliceRw)).json(); | |
| 109 | expect(body.audience.who).toBe("owner"); | |
| 110 | expect(body.audience.level).toBe("private"); | |
| 111 | expect(body.audience).toEqual(audienceFor(app.db, made.value, alice)); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a stranger asking about a private repository gets what a missing one gets", async () => { | |
| 115 | for (const token of [bobRw, ""]) { | |
| 116 | const hidden = await api("/repos/alice/notes", token); | |
| 117 | const missing = await api("/repos/alice/no-such-thing", token); | |
| 118 | expect(hidden.status).toBe(missing.status); | |
| 119 | expect(await hidden.text()).toBe(await missing.text()); | |
| 120 | } | |
| 121 | const hidden = await here(bobRw, `${servers.url}/alice/notes`); | |
| 122 | const missing = await here(bobRw, `${servers.url}/alice/no-such-thing`); | |
| 123 | expect(hidden.error).toBeDefined(); | |
| 124 | expect(hidden.error).toBe(missing.error); | |
| 125 | }); | |
| 126 | ||
| 127 | test("the MCP here tool repeats the line on a repository page", async () => { | |
| 128 | const r = await here(aliceRw, `${servers.url}/alice/notes`); | |
| 129 | expect(r.value.here.audience).toEqual( | |
| 130 | audienceFor(app.db, getRepo(app.db, "alice", "notes")!, alice), | |
| 131 | ); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a fork of a private repository is private, whatever is asked", async () => { | |
| 135 | const made = await forkRepo(app, alice, getRepo(app.db, "alice", "notes")!, "notes-copy", { | |
| 136 | visibility: "public", | |
| 137 | }); | |
| 138 | if (!made.ok) throw new Error(String(made.error)); | |
| 139 | expect(made.value.visibility).toBe("private"); | |
| 140 | expect(audienceFor(app.db, made.value, alice).who).toBe("owner"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a new app is private, and so is its repository", async () => { | |
| 144 | const made = await newApp(app, alice, { name: "vault" } as never); | |
| 145 | if (!made.ok) throw new Error(String(made.error)); | |
| 146 | const repo = getRepo(app.db, "alice", "vault")!; | |
| 147 | expect(repo.visibility).toBe("private"); | |
| 148 | expect(getAppByRepo(app.db, repo.id)!.access).toBe("private"); | |
| 149 | const line = audienceFor(app.db, repo, alice); | |
| 150 | expect(line.who).toBe("owner"); | |
| 151 | expect(line.level).toBe("private"); | |
| 152 | expect(line.sentence).toStartWith("Private:"); | |
| 153 | const owner = await here(aliceRw, appHost("vault")); | |
| 154 | expect(owner.value.audience).toEqual(line); | |
| 155 | }); | |
| 156 | ||
| 157 | test("the preview of a private app's pull answers its owner with the line, and a stranger like nothing", async () => { | |
| 158 | const repo = getRepo(app.db, "alice", "vault")!; | |
| 159 | const committed = await commitToBranch(app, alice, repo, { | |
| 160 | branch: "change", | |
| 161 | base: repo.defaultBranch, | |
| 162 | message: "A change", | |
| 163 | files: [{ path: "CHANGE.md", content: "hi\n" }], | |
| 164 | }); | |
| 165 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 166 | const opened = await openPull(app, alice, repo, { | |
| 167 | title: "A change", | |
| 168 | baseBranch: repo.defaultBranch, | |
| 169 | headBranch: "change", | |
| 170 | }); | |
| 171 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 172 | pull = opened.value.number; | |
| 173 | const owner = await here(aliceRw, appHost(`vault--${pull}`)); | |
| 174 | expect(owner.value.environment).toBe(`pull-${pull}`); | |
| 175 | expect(owner.value.audience.who).toBe("owner"); | |
| 176 | expect(owner.value.audience.level).toBe("private"); | |
| 177 | expect(owner.value.audience.sentence).toStartWith("Private:"); | |
| 178 | expect(owner.value.audience).toEqual(audienceFor(app.db, repo, alice)); | |
| 179 | const stranger = await here(bobRw, appHost(`vault--${pull}`)); | |
| 180 | const missing = await here(bobRw, appHost("nothing")); | |
| 181 | expect(stranger.error).toBeDefined(); | |
| 182 | expect(stranger.error).toBe(missing.error); | |
| 183 | }); | |
| 184 | ||
| 185 | test("a public app on a private repository tells its visitors nothing about who can read the code", async () => { | |
| 186 | const made = await createRepo(app, alice, { name: "shopfront" }); | |
| 187 | if (!made.ok) throw new Error(String(made.error)); | |
| 188 | expect(made.value.visibility).toBe("private"); | |
| 189 | const record = createAppRecord(app.db, { repoId: made.value.id, slug: "shopfront", access: "public" }); | |
| 190 | if (!record.ok) throw new Error(String(record.error)); | |
| 191 | const visitor = await here(bobRw, appHost("shopfront")); | |
| 192 | expect(visitor.value).toBeDefined(); | |
| 193 | expect(visitor.value.app).toBe("shopfront"); | |
| 194 | expect("audience" in visitor.value).toBe(false); | |
| 195 | const owner = await here(aliceRw, appHost("shopfront")); | |
| 196 | expect(owner.value.audience).toEqual(audienceFor(app.db, made.value, alice)); | |
| 197 | }); | |
| 198 | ||
| 199 | test("changing visibility is a receipted action, and the line moves in the same answer", async () => { | |
| 200 | const opened = await api("/repos/alice/notes", aliceRw, { | |
| 201 | method: "PATCH", | |
| 202 | body: JSON.stringify({ visibility: "public" }), | |
| 203 | }); | |
| 204 | expect(opened.status).toBe(200); | |
| 205 | const body: any = await opened.json(); | |
| 206 | expect(body.visibility).toBe("public"); | |
| 207 | expect(body.audience.who).toBe("anyone"); | |
| 208 | expect(body.audience.sentence).toStartWith("Public: anyone can read this;"); | |
| 209 | const receipt = app.db | |
| 210 | .query<{ payload: string }, [string]>( | |
| 211 | "SELECT payload FROM event WHERE kind = 'repo.settings_updated' AND repo_id = ?1", | |
| 212 | ) | |
| 213 | .get(getRepo(app.db, "alice", "notes")!.id); | |
| 214 | expect(JSON.parse(receipt!.payload).changed.visibility).toEqual({ from: "private", to: "public" }); | |
| 215 | // Now anyone may ask, and is told the same line. | |
| 216 | const seen: any = await (await api("/repos/alice/notes", bobRw)).json(); | |
| 217 | expect(seen.audience.who).toBe("anyone"); | |
| 218 | const closed: any = await ( | |
| 219 | await api("/repos/alice/notes", aliceRw, { | |
| 220 | method: "PATCH", | |
| 221 | body: JSON.stringify({ visibility: "private" }), | |
| 222 | }) | |
| 223 | ).json(); | |
| 224 | expect(closed.audience.who).toBe("owner"); | |
| 225 | expect(closed.audience.level).toBe("private"); | |
| 226 | }); | |
| 227 | }); | |
| 228 | ||
| 229 | test("who reads as one word: the owner, an organization's members, or anyone", () => { | |
| 230 | expect(whoOf({ visibility: "private", ownerKind: "user" })).toBe("owner"); | |
| 231 | expect(whoOf({ visibility: "private", ownerKind: "org" })).toBe("members"); | |
| 232 | expect(whoOf({ visibility: "public", ownerKind: "user" })).toBe("anyone"); | |
| 233 | expect(whoOf({ visibility: "public", ownerKind: "org" })).toBe("anyone"); | |
| 234 | }); |