Public: anyone can read this; the crew can change it.
The API's "not found" and "authenticate" sentences live in one place, read by respond.ts and proposal tokens #5596
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5583-the-api-s-not-found-and-authenticate-sen → main 1 commit fork
3 files
changed +38
−6
src/actions/proposal_tokens.ts +2 −4
| @@ -16,15 +16,13 @@ import { | ||
| 16 | 16 | import { canWrite, getRepo, type Repo } from "../core/repos.ts"; |
| 17 | 17 | import type { User } from "../core/users.ts"; |
| 18 | 18 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 19 | import { NOT_FOUND_WORDS, UNAUTHORIZED_WORDS } from "../http/respond.ts"; | |
| 19 | 20 | |
| 20 | 21 | export interface ProposalTokenError { |
| 21 | 22 | readonly code: "unauthorized" | "not_found" | "invalid_child" | "invalid_origin"; |
| 22 | 23 | readonly message: string; |
| 23 | 24 | } |
| 24 | 25 | |
| 25 | /** The API's own words for a repository that is missing or not yours. */ | |
| 26 | export const NOT_FOUND_WORDS = "no such resource here, or it is not visible to you"; | |
| 27 | ||
| 28 | 26 | const fail = (code: ProposalTokenError["code"], message: string) => |
| 29 | 27 | err<ProposalTokenError>({ code, message }); |
| 30 | 28 | |
| @@ -36,7 +34,7 @@ function mine( | ||
| 36 | 34 | actor: User | undefined, |
| 37 | 35 | repo: Repo | undefined, |
| 38 | 36 | ): Result<{ repo: Repo; actor: User }, ProposalTokenError> { |
| 39 | if (!actor) return fail("unauthorized", "authenticate with a bearer token or account credentials"); | |
| 37 | if (!actor) return fail("unauthorized", UNAUTHORIZED_WORDS); | |
| 40 | 38 | if (!repo || !canWrite(app.db, repo, actor)) return fail("not_found", NOT_FOUND_WORDS); |
| 41 | 39 | return ok({ repo, actor }); |
| 42 | 40 | } |
src/http/respond.ts +7 −2
| @@ -34,6 +34,11 @@ export const apiError = ( | ||
| 34 | 34 | message: string, |
| 35 | 35 | ): Response => json({ error: code, message }, status); |
| 36 | 36 | |
| 37 | /** The API's own words for a resource that is missing or not visible to you. */ | |
| 38 | export const NOT_FOUND_WORDS = "no such resource here, or it is not visible to you"; | |
| 39 | /** The API's own words for a request that carries no credential. */ | |
| 40 | export const UNAUTHORIZED_WORDS = "authenticate with a bearer token or account credentials"; | |
| 41 | ||
| 37 | 42 | // `not_found` deliberately conflates "no such resource" with "not visible to |
| 38 | 43 | // you" so a private repo never leaks its existence — the message keeps that |
| 39 | 44 | // ambiguity intact. |
| @@ -41,13 +46,13 @@ export const notFound = () => | ||
| 41 | 46 | apiError( |
| 42 | 47 | "not_found", |
| 43 | 48 | 404, |
| 44 | "no such resource here, or it is not visible to you", | |
| 49 | NOT_FOUND_WORDS, | |
| 45 | 50 | ); |
| 46 | 51 | export const unauthorized = () => |
| 47 | 52 | apiError( |
| 48 | 53 | "unauthorized", |
| 49 | 54 | 401, |
| 50 | "authenticate with a bearer token or account credentials", | |
| 55 | UNAUTHORIZED_WORDS, | |
| 51 | 56 | ); |
| 52 | 57 | // A password door past the sign-in windows (`core/signin-window.ts`). |
| 53 | 58 | export const tooManyAttempts = (retryAfter: number) => |
test/respond-words.test.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The API's "not found" and "authenticate" sentences are said from one place, | |
| 3 | * `src/http/respond.ts`; the files that say them import them rather than | |
| 4 | * typing a copy that could drift. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { NOT_FOUND_WORDS, UNAUTHORIZED_WORDS } from "../src/http/respond.ts"; | |
| 9 | ||
| 10 | const SENTENCES = { NOT_FOUND_WORDS, UNAUTHORIZED_WORDS }; | |
| 11 | const READERS = ["src/http/respond.ts", "src/actions/proposal_tokens.ts"]; | |
| 12 | const root = new URL("..", import.meta.url).pathname; | |
| 13 | ||
| 14 | describe("respond words", () => { | |
| 15 | for (const [name, words] of Object.entries(SENTENCES)) { | |
| 16 | test(`${name} is a sentence`, () => { | |
| 17 | expect(words.trim().length).toBeGreaterThan(0); | |
| 18 | }); | |
| 19 | ||
| 20 | for (const file of READERS) { | |
| 21 | test(`${file} does not type ${name} out`, async () => { | |
| 22 | const source = await Bun.file(root + file).text(); | |
| 23 | const typed = source.split(`"${words}"`).length - 1; | |
| 24 | // respond.ts holds the one definition; every other reader has none. | |
| 25 | expect(typed).toBe(file === "src/http/respond.ts" ? 1 : 0); | |
| 26 | }); | |
| 27 | } | |
| 28 | } | |
| 29 | }); |
new above
offline