Public: anyone can read this; the crew can change it.
An origin can be connected read-only, and every write-through path refuses it #10617
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10519-an-origin-can-be-connected-read-only-and → main 4 commits fork 2 threads · 2 unresolved
22 files
changed +354
−10
docs/features/origin-read-only.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Connecting an origin read-only (#10519) | |
| 2 | ||
| 3 | Tick **Read-only** when you connect an account by token (Settings → Origins, either form), pass `"readOnly": true` to `POST /api/v1/user/origins`, or answer `yes` to the last prompt of `c` in the terminal's Connected origins screen. Reading and syncing go on; nothing is ever written back: no comments, issues, reviews, branches, forks, proposals, pushes or mirror-out. | |
| 4 | ||
| 5 | - **What you see:** the origins list says "read-only: nothing is written back" on the row (the terminal row says `read-only`). A refused write says so in a plain sentence; the REST door answers `409` with `"error": "origin_read_only"`. | |
| 6 | - **Merging** on a mirror is never done from here, read-only or not (`mirrored`). | |
| 7 | - **Stored:** `origin.read_only`, default 0, so every connection from before stays as it was. The token stays sealed either way. | |
| 8 | - **Try it:** connect with Read-only ticked, mirror a repository, comment on it: refused, and the origin saw no write. | |
| 9 | - Test: `test/origin-read-only.test.ts`. |
src/actions/branches.ts +1
| @@ -180,6 +180,7 @@ export async function originPush( | ||
| 180 | 180 | if (!link) return ok(undefined); |
| 181 | 181 | if (link.origin.userId !== actor.id) return err("origin_owner_only"); |
| 182 | 182 | if (link.cloneUrl === "") return err("origin_unreachable"); |
| 183 | if (link.origin.readOnly) return err("origin_read_only"); | |
| 183 | 184 | if (writesPaused(app, link.origin.id) !== undefined) |
| 184 | 185 | return err("origin_paused"); |
| 185 | 186 | if (denied(link.origin, link.fullName)) return err("origin_protected"); |
src/actions/contribute.ts +3
| @@ -75,6 +75,7 @@ export type ForkOriginError = | ||
| 75 | 75 | | "origin_refused" |
| 76 | 76 | /** The connection is not verified against the operator's rules yet. */ |
| 77 | 77 | | "origin_paused" |
| 78 | | "origin_read_only" | |
| 78 | 79 | /** The origin took the request and the fork never appeared in time. */ |
| 79 | 80 | | "fork_timeout"; |
| 80 | 81 | |
| @@ -123,6 +124,7 @@ export async function forkOrigin( | ||
| 123 | 124 | // Somebody else's connected account does not exist for you (invariant 9). |
| 124 | 125 | const origin = getOriginFor(app.db, user.value.id, originId); |
| 125 | 126 | if (!origin) return err("not_found"); |
| 127 | if (origin.readOnly) return err("origin_read_only"); | |
| 126 | 128 | if (writesPaused(app, origin.id) !== undefined) return err("origin_paused"); |
| 127 | 129 | const provider = providerForOrigin(app, origin); |
| 128 | 130 | if (!provider.ok) return err("origin_unreachable"); |
| @@ -271,6 +273,7 @@ export async function contribute( | ||
| 271 | 273 | if (!proven.ok) return proven; |
| 272 | 274 | const origin = getOriginFor(app.db, user.value.id, input.originId); |
| 273 | 275 | if (!origin) return err("not_found"); |
| 276 | if (origin.readOnly) return err("origin_read_only"); | |
| 274 | 277 | if (writesPaused(app, origin.id) !== undefined) return err("origin_paused"); |
| 275 | 278 | const branch = input.branch.trim(); |
| 276 | 279 | if (!isSafeRef(branch) || branch.startsWith("refs/")) |
src/actions/origins.ts +5
| @@ -167,6 +167,8 @@ export interface ConnectInput { | ||
| 167 | 167 | /** Set when the user picked a forge explicitly (GitHub Enterprise). */ |
| 168 | 168 | kind?: OriginKind; |
| 169 | 169 | token: string; |
| 170 | /** Connect read-only: every write-through on its mirrors refuses. */ | |
| 171 | readOnly?: boolean; | |
| 170 | 172 | } |
| 171 | 173 | |
| 172 | 174 | /** |
| @@ -232,6 +234,7 @@ export async function connectOrigin( | ||
| 232 | 234 | token: input.token.trim(), |
| 233 | 235 | authKind: "pat", |
| 234 | 236 | software: softwareOf(found.value.software), |
| 237 | ...(input.readOnly === true ? { readOnly: true } : {}), | |
| 235 | 238 | }); |
| 236 | 239 | if (!stored.ok) return stored; |
| 237 | 240 | if (guard.value.checked) noteGuardPassed(app, stored.value.id); |
| @@ -362,6 +365,7 @@ function storeOrigin( | ||
| 362 | 365 | refreshToken?: string; |
| 363 | 366 | expiresAt?: string | null; |
| 364 | 367 | software?: string; |
| 368 | readOnly?: boolean; | |
| 365 | 369 | }, |
| 366 | 370 | ): Result<Origin, "already_connected"> { |
| 367 | 371 | // Connecting an account whose credential died is re-authorizing it: the |
| @@ -406,6 +410,7 @@ function storeOrigin( | ||
| 406 | 410 | : { refreshToken: input.refreshToken }), |
| 407 | 411 | ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }), |
| 408 | 412 | ...(input.software === undefined ? {} : { software: input.software }), |
| 413 | ...(input.readOnly === true ? { readOnly: true } : {}), | |
| 409 | 414 | }); |
| 410 | 415 | if (!created.ok) return created; |
| 411 | 416 | // The payload names the host and the login and nothing else: an audit |
src/core/migration-pins/20261110090000-origin-read-only.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 75cb52dfd21642bc2a202621e2728eda05856f275a572575ba51f48bcacb913e |
src/core/migrations/20261110090000-origin-read-only.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // An origin connected read-only: every write-through on its mirrors refuses | |
| 2 | // (`origin_read_only`). Off for every row from before. Additive. | |
| 3 | export default ` | |
| 4 | ALTER TABLE origin ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0; | |
| 5 | `; |
src/core/origins.ts +7 −2
| @@ -36,6 +36,7 @@ interface OriginRow { | ||
| 36 | 36 | expires_at: string | null; |
| 37 | 37 | needs_reauth: number; |
| 38 | 38 | software: string; |
| 39 | read_only: number; | |
| 39 | 40 | } |
| 40 | 41 | |
| 41 | 42 | function toOrigin(row: OriginRow): Origin { |
| @@ -53,6 +54,7 @@ function toOrigin(row: OriginRow): Origin { | ||
| 53 | 54 | expiresAt: row.expires_at, |
| 54 | 55 | needsReauth: row.needs_reauth === 1, |
| 55 | 56 | software: row.software, |
| 57 | readOnly: row.read_only === 1, | |
| 56 | 58 | }; |
| 57 | 59 | } |
| 58 | 60 | |
| @@ -72,6 +74,8 @@ export interface CreateOriginInput { | ||
| 72 | 74 | expiresAt?: string | null; |
| 73 | 75 | /** `forge` for another forge; see `Origin.software`. */ |
| 74 | 76 | software?: string; |
| 77 | /** Connect read-only; set once here and never cleared silently. @default false */ | |
| 78 | readOnly?: boolean; | |
| 75 | 79 | } |
| 76 | 80 | |
| 77 | 81 | export type CreateOriginError = "already_connected"; |
| @@ -97,8 +101,8 @@ export function createOrigin( | ||
| 97 | 101 | const originId = id(); |
| 98 | 102 | db.query( |
| 99 | 103 | `INSERT INTO origin (id, user_id, kind, api_url, web_url, login, token_enc, |
| 100 | auth_kind, refresh_token_enc, expires_at, software) | |
| 101 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`, | |
| 104 | auth_kind, refresh_token_enc, expires_at, software, read_only) | |
| 105 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`, | |
| 102 | 106 | ).run( |
| 103 | 107 | originId, |
| 104 | 108 | input.userId, |
| @@ -113,6 +117,7 @@ export function createOrigin( | ||
| 113 | 117 | : seal(dataDir, input.refreshToken, originId), |
| 114 | 118 | input.expiresAt ?? null, |
| 115 | 119 | input.software ?? "", |
| 120 | input.readOnly === true ? 1 : 0, | |
| 116 | 121 | ); |
| 117 | 122 | return ok(getOriginById(db, originId)!); |
| 118 | 123 | } |
src/http/api.ts +6
| @@ -680,6 +680,12 @@ export function originAware(error: string, status = 422): Response { | ||
| 680 | 680 | 409, |
| 681 | 681 | "the operator protects this repository at its origin: nothing is pushed to it from here; propose the change from your fork", |
| 682 | 682 | ); |
| 683 | if (error === "origin_read_only") | |
| 684 | return apiError( | |
| 685 | "origin_read_only", | |
| 686 | 409, | |
| 687 | "this account was connected read-only, so nothing is written through it; nothing was changed", | |
| 688 | ); | |
| 683 | 689 | if (error === "origin_paused") |
| 684 | 690 | return apiError( |
| 685 | 691 | "origin_paused", |
src/http/api/user.ts +1
| @@ -269,6 +269,7 @@ export function userRoutes(router: Router<Handler>): void { | ||
| 269 | 269 | ? { kind } |
| 270 | 270 | : {}), |
| 271 | 271 | token: str(input.token), |
| 272 | readOnly: input.readOnly === true, | |
| 272 | 273 | }); |
| 273 | 274 | if (!connected.ok) { |
| 274 | 275 | // A refusal that names its own reason (a missing scope) keeps it; |
src/origins/credential.ts +6
| @@ -72,6 +72,8 @@ export function credentialFor( | ||
| 72 | 72 | software: origin.software, |
| 73 | 73 | token: () => resolveToken(app, origin.id), |
| 74 | 74 | writable: () => { |
| 75 | if (origin.readOnly) | |
| 76 | return { ok: false, error: { error: "forbidden", message: READ_ONLY } }; | |
| 75 | 77 | const paused = writesPaused(app, origin.id); |
| 76 | 78 | return paused === undefined |
| 77 | 79 | ? { ok: true, value: true } |
| @@ -247,6 +249,10 @@ const GUARD_GIVE_UP_MS = 24 * 60 * 60_000; | ||
| 247 | 249 | /** The sweep's pause between requests: one origin at a time, gently. */ |
| 248 | 250 | const SWEEP_PAUSE_MS = 500; |
| 249 | 251 | |
| 252 | /** What a write through a read-only origin is told (`origin_read_only`), everywhere. */ | |
| 253 | export const READ_ONLY = | |
| 254 | "This account was connected read-only, so nothing is written through it. Reading and syncing go on."; | |
| 255 | ||
| 250 | 256 | /** What a write through a paused origin is told, everywhere. */ |
| 251 | 257 | export const WRITES_PAUSED = |
| 252 | 258 | "This forge could not verify this connection against the operator's rules, so writes through it are paused. Reads and sync go on. Check it again under Settings → Origins."; |
src/origins/fork-route.ts +2 −1
| @@ -33,7 +33,7 @@ import type { Repo } from "../core/repos.ts"; | ||
| 33 | 33 | import { denied } from "./operator.ts"; |
| 34 | 34 | import { resolveOid } from "../git/merge.ts"; |
| 35 | 35 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 36 | import { writesPaused } from "./credential.ts"; | |
| 36 | import { READ_ONLY, writesPaused } from "./credential.ts"; | |
| 37 | 37 | import { redact, secretsIn } from "./credentials.ts"; |
| 38 | 38 | import { pushToOrigin } from "./git-mirror.ts"; |
| 39 | 39 | import type { Provider, RemoteRepo } from "./types.ts"; |
| @@ -243,6 +243,7 @@ export async function pushToFork( | ||
| 243 | 243 | ): Promise<Result<string, string>> { |
| 244 | 244 | // Its callers ask too; this is the push itself asking, so no path that |
| 245 | 245 | // forgets cannot write through an origin whose account is not verified. |
| 246 | if (origin.readOnly) return err(READ_ONLY); | |
| 246 | 247 | const paused = writesPaused(app, origin.id); |
| 247 | 248 | if (paused !== undefined) return err(paused); |
| 248 | 249 | const tip = await resolveOid(dir, `refs/heads/${branch}`); |
src/origins/push-through.ts +4 −1
| @@ -37,7 +37,7 @@ import { FORWARDED_GIT_ENV, type RefUpdate } from "../git/hook-protocol.ts"; | ||
| 37 | 37 | import { fetchInto, resolveOid, writeRef } from "../git/merge.ts"; |
| 38 | 38 | import { repoDir } from "../git/repos.ts"; |
| 39 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 40 | import { providerForOrigin, writesPaused } from "./credential.ts"; | |
| 40 | import { providerForOrigin, READ_ONLY, writesPaused } from "./credential.ts"; | |
| 41 | 41 | import { redact, secretsIn } from "./credentials.ts"; |
| 42 | 42 | import { pushToOrigin, snapshotRefs } from "./git-mirror.ts"; |
| 43 | 43 | import { denied } from "./operator.ts"; |
| @@ -199,6 +199,8 @@ async function wayThrough( | ||
| 199 | 199 | message: "this mirror has no upstream clone URL; re-mirror it", |
| 200 | 200 | }); |
| 201 | 201 | |
| 202 | if (link.origin.readOnly) | |
| 203 | return err({ message: `${link.origin.webUrl}: ${READ_ONLY}` }); | |
| 202 | 204 | const paused = writesPaused(app, link.origin.id); |
| 203 | 205 | if (paused !== undefined) |
| 204 | 206 | return err({ message: `${link.origin.webUrl}: ${paused}` }); |
| @@ -258,6 +260,7 @@ export async function pushToMirrorTarget( | ||
| 258 | 260 | }; |
| 259 | 261 | const origin = getOriginById(app.db, target.originId); |
| 260 | 262 | if (!origin) return fail("the origin was disconnected; reconnect it"); |
| 263 | if (origin.readOnly) return fail(READ_ONLY); | |
| 261 | 264 | const provider = providerForOrigin(app, origin); |
| 262 | 265 | if (!provider.ok) return fail("the origin needs to be reconnected"); |
| 263 | 266 | const authEnv = await provider.value.gitAuthEnv(target.cloneUrl); |
src/origins/types.ts +2
| @@ -47,6 +47,8 @@ export interface Origin { | ||
| 47 | 47 | * forge (stored as a Gitea origin, which is the dialect it speaks), else "". |
| 48 | 48 | */ |
| 49 | 49 | readonly software: string; |
| 50 | /** Connected read-only: nothing is ever written through it (`origin_read_only`). */ | |
| 51 | readonly readOnly: boolean; | |
| 50 | 52 | } |
| 51 | 53 | |
| 52 | 54 | /** A repository as the origin describes it — the import picker's rows. */ |
src/origins/write-through.ts +3
| @@ -24,6 +24,8 @@ export type WriteThroughError = | ||
| 24 | 24 | | "origin_refused" |
| 25 | 25 | /** The account behind the origin is not verified against the operator's rules yet. */ |
| 26 | 26 | | "origin_paused" |
| 27 | /** The origin was connected read-only: nothing is written through it. */ | |
| 28 | | "origin_read_only" | |
| 27 | 29 | /** The operator protects the origin repository: propose from a fork. */ |
| 28 | 30 | | "origin_protected"; |
| 29 | 31 | |
| @@ -53,6 +55,7 @@ export function writeThroughFor( | ||
| 53 | 55 | const link = originForRepo(app.db, repo.id); |
| 54 | 56 | if (!link) return ok(undefined); |
| 55 | 57 | if (link.origin.userId !== actor.id) return err("origin_owner_only"); |
| 58 | if (link.origin.readOnly) return err("origin_read_only"); | |
| 56 | 59 | if (writesPaused(app, link.origin.id) !== undefined) |
| 57 | 60 | return err("origin_paused"); |
| 58 | 61 | const provider = providerForOrigin(app, link.origin); |
src/tui/screens/common.ts +1
| @@ -982,6 +982,7 @@ const MESSAGES: Record<string, string> = { | ||
| 982 | 982 | mirrored: "this repo mirrors an origin — do it there", |
| 983 | 983 | origin_owner_only: "only whoever connected this origin can write through it", |
| 984 | 984 | origin_refused: "the origin refused that; nothing changed here", |
| 985 | origin_read_only: "this account was connected read-only; nothing is written through it", | |
| 985 | 986 | origin_paused: "this connection is not verified yet; writes are paused", |
| 986 | 987 | origin_protected: "the operator protects this repository at its origin: nothing is pushed to it from here; propose the change from your fork", |
| 987 | 988 | origin_unreachable: "the origin could not be reached; nothing changed", |
src/tui/screens/connections.ts +16 −6
| @@ -55,7 +55,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 55 | 55 | return { |
| 56 | 56 | title: `${host(o.webUrl)} · ${o.login}`, |
| 57 | 57 | ...(o.lastError !== "" || writesPaused(f.app, o.id) !== undefined ? { tone: "danger" as const } : {}), |
| 58 | secondary: `${o.software === "forge" ? "another forge · " : ""}${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`, | |
| 58 | secondary: `${o.software === "forge" ? "another forge · " : ""}${o.readOnly ? "read-only · " : ""}${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`, | |
| 59 | 59 | meta: o.lastError !== "" ? o.lastError : writesPaused(f.app, o.id) !== undefined ? "writes paused" : o.authKind === "oauth" ? "OAuth" : "token", |
| 60 | 60 | }; |
| 61 | 61 | }), |
| @@ -81,11 +81,21 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 81 | 81 | mask: true, |
| 82 | 82 | onSubmit: (token) => { |
| 83 | 83 | if (token.trim() === "") return "Paste the token."; |
| 84 | ctx.toast(`Connecting ${hostText}…`); | |
| 85 | return connectOrigin(f.app, user, { host: hostText, token }).then((r) => { | |
| 86 | ctx.toast(r.ok ? `Connected ${r.value.login} at ${host(r.value.webUrl)}.` : connectRefusal(r.error).message ?? connectWords(connectRefusal(r.error).code), r.ok ? "success" : "danger"); | |
| 87 | ctx.invalidate(); | |
| 88 | }); | |
| 84 | return later(ctx, () => | |
| 85 | promptScreen(ctx, screen, { | |
| 86 | title: "Connect an origin", | |
| 87 | label: "Read-only? Type yes to never write back to it", | |
| 88 | hint: "Leave empty to connect the ordinary way.", | |
| 89 | onSubmit: (answer) => { | |
| 90 | const readOnly = /^y(es)?$/i.test(answer.trim()); | |
| 91 | ctx.toast(`Connecting ${hostText}…`); | |
| 92 | return connectOrigin(f.app, user, { host: hostText, token, readOnly }).then((r) => { | |
| 93 | ctx.toast(r.ok ? `Connected ${r.value.login} at ${host(r.value.webUrl)}${readOnly ? ", read-only" : ""}.` : connectRefusal(r.error).message ?? connectWords(connectRefusal(r.error).code), r.ok ? "success" : "danger"); | |
| 94 | ctx.invalidate(); | |
| 95 | }); | |
| 96 | }, | |
| 97 | }), | |
| 98 | ); | |
| 89 | 99 | }, |
| 90 | 100 | }), |
| 91 | 101 | ); |
src/web/pages/browse.ts +2
| @@ -950,6 +950,8 @@ export function forkFailure(error: string): string { | ||
| 950 | 950 | return "The origin could not be reached. Nothing was created."; |
| 951 | 951 | if (error === "origin_refused") |
| 952 | 952 | return "The origin refused to fork that repository."; |
| 953 | if (error === "origin_read_only") | |
| 954 | return "This account was connected read-only, so nothing is written through it."; | |
| 953 | 955 | if (error === "origin_paused") |
| 954 | 956 | return "This connection is not verified yet, so writes through it are paused. Check it again under Settings → Origins."; |
| 955 | 957 | if (error === "mirror_failed") |
src/web/pages/edit.ts +2
| @@ -128,6 +128,8 @@ export function editErrorText(error: unknown): string { | ||
| 128 | 128 | return "The origin refused this change, so nothing landed here either."; |
| 129 | 129 | if (error === "origin_protected") |
| 130 | 130 | return "The operator protects this repository at its origin, so nothing is pushed to it from here. Propose the change from your fork."; |
| 131 | if (error === "origin_read_only") | |
| 132 | return "This account was connected read-only, so nothing is written through it."; | |
| 131 | 133 | if (error === "origin_paused") |
| 132 | 134 | return "This connection is not verified yet, so writes through it are paused. Check it again under Settings → Origins."; |
| 133 | 135 | return "That edit could not be saved."; |
src/web/pages/origins.ts +15
| @@ -281,6 +281,7 @@ function originCard(origin: Origin, mirrors: number, paused = false): Html { | ||
| 281 | 281 | origin.webUrl, |
| 282 | 282 | )}`, |
| 283 | 283 | meta: html`${origin.authKind === "oauth" ? "OAuth" : "personal access token"} |
| 284 | ${when(origin.readOnly, () => html` · read-only: nothing is written back`)} | |
| 284 | 285 | · |
| 285 | 286 | ${origin.lastSyncAt === null |
| 286 | 287 | ? html`never synced` |
| @@ -591,11 +592,23 @@ function tokenSection(values: ConnectValues): Html { | ||
| 591 | 592 | autocomplete: "off", |
| 592 | 593 | hint: "It is never displayed after this.", |
| 593 | 594 | })} |
| 595 | ${readOnlyField()} | |
| 594 | 596 | ${tokenNeeds(values.tokenHost)} |
| 595 | 597 | <div class="form-actions">${btn("Connect")}</div> |
| 596 | 598 | </form>`; |
| 597 | 599 | } |
| 598 | 600 | |
| 601 | /** The one Read-only checkbox, on every way to connect by token. */ | |
| 602 | function readOnlyField(): Html { | |
| 603 | return uiField({ | |
| 604 | label: "Read-only", | |
| 605 | name: "readOnly", | |
| 606 | type: "checkbox", | |
| 607 | value: "1", | |
| 608 | hint: "Nothing is ever written back to that account: no comments, pulls, pushes or merges.", | |
| 609 | }); | |
| 610 | } | |
| 611 | ||
| 599 | 612 | const OLDER_FORGE_DOC = "/trevato/forge/blob/main/docs/features/forge-origin-older.md#connecting-from-an-older-forge"; |
| 600 | 613 | |
| 601 | 614 | /** The one line for a forge older than 0.7.346 (#9626): it cannot start a connection, this side can. */ |
| @@ -668,6 +681,7 @@ function forgeSection(ctx: Ctx, values: ConnectValues): Html { | ||
| 668 | 681 | autocomplete: "off", |
| 669 | 682 | hint: "Read is enough to bring an app; private repositories come with it.", |
| 670 | 683 | })} |
| 684 | ${readOnlyField()} | |
| 671 | 685 | <p class="muted small">Make the token there: ${tokens} (Settings → Access tokens).</p> |
| 672 | 686 | <div class="form-actions"> |
| 673 | 687 | ${btn("Connect that forge", { variant: "primary", icon: "globe" })} |
| @@ -972,6 +986,7 @@ export async function originConnectToken(ctx: Ctx): Promise<Response> { | ||
| 972 | 986 | const connected = await connectOrigin(ctx.app, ctx.auth.user, { |
| 973 | 987 | host, |
| 974 | 988 | token, |
| 989 | readOnly: field(form, "readOnly") === "1", | |
| 975 | 990 | }); |
| 976 | 991 | if (!connected.ok) { |
| 977 | 992 | // The host comes back; the token deliberately does not. Re-typing a |
test/origin-read-only.test.ts added +259
| @@ -0,0 +1,259 @@ | ||
| 1 | /** | |
| 2 | * An origin connected read-only (#10519): every write-through on a repo | |
| 3 | * mirrored from it answers `origin_read_only` before a request leaves the | |
| 4 | * forge, and a read (sync) still works. The fake remote records every request | |
| 5 | * it receives, so "refused" is proved by "the remote saw no write". | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { forkOrigin, contribute } from "../src/actions/contribute.ts"; | |
| 13 | import { createBranch } from "../src/actions/branches.ts"; | |
| 14 | import { addCommentThrough, openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "../src/actions/issues.ts"; | |
| 15 | import { mergePull, openPull, submitReview } from "../src/actions/pulls.ts"; | |
| 16 | import { createSession } from "../src/core/sessions.ts"; | |
| 17 | import { getOriginById, listOrigins } from "../src/core/origins.ts"; | |
| 18 | import { setMirrorTarget } from "../src/core/migrate_mirror.ts"; | |
| 19 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 20 | import { getRepo } from "../src/core/repos.ts"; | |
| 21 | import { getUserByName } from "../src/core/users.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | import { pushThrough, pushToMirrorTarget } from "../src/origins/push-through.ts"; | |
| 24 | import { syncsSettled } from "../src/origins/sync.ts"; | |
| 25 | import { mirrorAndWait, newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 26 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 27 | import { proveAll } from "./helpers/verified.ts"; | |
| 28 | ||
| 29 | setDefaultTimeout(60_000); | |
| 30 | ||
| 31 | const LOGIN = "octo"; | |
| 32 | const TOKEN = "octo-readonly-secret-5d1c"; | |
| 33 | const PASSWORD = "alice-login-secret"; | |
| 34 | const basic = (u: string, p: string) => `Basic ${Buffer.from(`${u}:${p}`).toString("base64")}`; | |
| 35 | ||
| 36 | describe("an origin connected read-only", () => { | |
| 37 | const dirs: string[] = []; | |
| 38 | let app: App; | |
| 39 | let servers: Servers; | |
| 40 | let originApp: App; | |
| 41 | let originServers: Servers; | |
| 42 | let fake: FakeForge; | |
| 43 | let originId = ""; | |
| 44 | ||
| 45 | const api = (path: string, init?: RequestInit) => { | |
| 46 | const headers = new Headers(init?.headers); | |
| 47 | headers.set("content-type", "application/json"); | |
| 48 | headers.set("authorization", basic("alice", PASSWORD)); | |
| 49 | return fetch(`${servers.url}${path}`, { ...init, headers }); | |
| 50 | }; | |
| 51 | const writes = () => fake.state.requests.filter((r) => r.method !== "GET" && r.method !== "HEAD"); | |
| 52 | const alice = () => getUserByName(app.db, "alice")!; | |
| 53 | const repo = () => getRepo(app.db, "alice", "vault")!; | |
| 54 | const ghost = (n = 1): any => ({ repoId: repo().id, number: n }); | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | for (const p of ["forge-ro-", "forge-ro-up-"]) dirs.push(await mkdtemp(join(tmpdir(), p))); | |
| 58 | originApp = createApp({ dataDir: dirs[1]!, port: 0 }); | |
| 59 | originServers = await serve(originApp); | |
| 60 | await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN }); | |
| 61 | const oh = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) }; | |
| 62 | await fetch(`${originServers.url}/api/v1/repos`, { | |
| 63 | method: "POST", | |
| 64 | headers: oh, | |
| 65 | body: JSON.stringify({ name: "vault", visibility: "public" }), | |
| 66 | }); | |
| 67 | await fetch(`${originServers.url}/api/v1/repos/octo/vault/contents`, { | |
| 68 | method: "POST", | |
| 69 | headers: oh, | |
| 70 | body: JSON.stringify({ branch: "main", message: "first", files: [{ path: "a.txt", content: "one\n" }] }), | |
| 71 | }); | |
| 72 | const state = newFakeState("github", LOGIN, TOKEN); | |
| 73 | state.repos.push({ | |
| 74 | id: 9, | |
| 75 | fullName: "octo/vault", | |
| 76 | cloneUrl: `${originServers.url}/octo/vault.git`, | |
| 77 | defaultBranch: "main", | |
| 78 | htmlUrl: `${originServers.url}/octo/vault`, | |
| 79 | push: true, | |
| 80 | }); | |
| 81 | fake = serveFakeForge(state); | |
| 82 | ||
| 83 | app = createApp({ dataDir: dirs[0]!, port: 0 }); | |
| 84 | servers = await serve(app); | |
| 85 | const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD }); | |
| 86 | if (!made.ok) throw new Error(made.error); | |
| 87 | const connected = (await ( | |
| 88 | await api("/api/v1/user/origins", { | |
| 89 | method: "POST", | |
| 90 | body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN, readOnly: true }), | |
| 91 | }) | |
| 92 | ).json()) as { id: string }; | |
| 93 | originId = connected.id; | |
| 94 | await mirrorAndWait( | |
| 95 | await api(`/api/v1/user/origins/${originId}/mirror`, { | |
| 96 | method: "POST", | |
| 97 | body: JSON.stringify({ fullName: "octo/vault" }), | |
| 98 | }), | |
| 99 | ); | |
| 100 | proveAll(app); | |
| 101 | }); | |
| 102 | ||
| 103 | afterAll(async () => { | |
| 104 | await syncsSettled(); | |
| 105 | fake.stop(); | |
| 106 | servers.stop(); | |
| 107 | originServers.stop(); | |
| 108 | app.close(); | |
| 109 | originApp.close(); | |
| 110 | for (const d of dirs) await rm(d, { recursive: true, force: true }); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the flag is stored, the token is still sealed and never shown", async () => { | |
| 114 | expect(getOriginById(app.db, originId)?.readOnly).toBe(true); | |
| 115 | const row = app.db.query<{ token_enc: string }, [string]>("SELECT token_enc FROM origin WHERE id = ?1").get(originId)!; | |
| 116 | expect(row.token_enc).not.toContain(TOKEN); | |
| 117 | const listed = await (await api("/api/v1/user/origins")).text(); | |
| 118 | expect(listed).not.toContain(TOKEN); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a read (sync) still succeeds", async () => { | |
| 122 | const res = await api("/api/v1/repos/alice/vault/sync", { method: "POST" }); | |
| 123 | expect(res.status).toBeLessThan(300); | |
| 124 | await syncsSettled(); | |
| 125 | }); | |
| 126 | ||
| 127 | test("every write-through refuses and the remote sees no write", async () => { | |
| 128 | const before = writes().length; | |
| 129 | const r = repo(); | |
| 130 | const a = alice(); | |
| 131 | const refused = (result: { ok: boolean; error?: unknown }) => { | |
| 132 | expect(result.ok).toBe(false); | |
| 133 | expect(result.error).toBe("origin_read_only"); | |
| 134 | }; | |
| 135 | ||
| 136 | refused(await openIssueThrough(app, a, r, { title: "hello" })); | |
| 137 | refused(await addCommentThrough(app, a, r, ghost(), "hi")); | |
| 138 | refused(await setIssueStateThrough(app, a, r, ghost(), "closed")); | |
| 139 | refused(await setIssueLabelsThrough(app, a, r, ghost(), ["bug"])); | |
| 140 | refused(await openPull(app, a, r, { title: "t", baseBranch: "main", headBranch: "x" })); | |
| 141 | refused(await submitReview(app, a, r, { ...ghost(), baseBranch: "main", headBranch: "main", crossRepo: false, sealed: false, baseOid: "", headOid: "" } as never, { state: "approved", body: "" })); | |
| 142 | refused(await createBranch(app, a, r, { name: "feature", from: "main" } as never)); | |
| 143 | refused(await forkOrigin(app, a, originId, "octo/vault")); | |
| 144 | refused( | |
| 145 | await contribute(app, a, { | |
| 146 | originId, | |
| 147 | fullName: "octo/vault", | |
| 148 | branch: "work", | |
| 149 | files: [{ path: "x.txt", content: "x" }], | |
| 150 | } as never), | |
| 151 | ); | |
| 152 | ||
| 153 | // Merging on a mirror is refused outright, whatever the origin's flag. | |
| 154 | const merged = await mergePull(app, a, r, { ...ghost(), originHeadRepo: null } as never, "merge"); | |
| 155 | expect(merged.ok).toBe(false); | |
| 156 | ||
| 157 | // A push forwarded to the origin. | |
| 158 | const pushed = await pushThrough(app, r, [{ ref: "refs/heads/main", oldOid: "0".repeat(40), newOid: "1".repeat(40) }], {}, a.id); | |
| 159 | expect(pushed.ok).toBe(false); | |
| 160 | if (!pushed.ok) expect(pushed.error.message).toContain("connected read-only"); | |
| 161 | ||
| 162 | // Mirror-out. | |
| 163 | setMirrorTarget(app.db, { repoId: r.id, originId, fullName: "octo/vault", cloneUrl: "http://127.0.0.1:1/x.git", tips: {} }); | |
| 164 | const out = await pushToMirrorTarget(app, r); | |
| 165 | expect(out.ok).toBe(false); | |
| 166 | if (!out.ok) expect(out.error.message).toContain("connected read-only"); | |
| 167 | ||
| 168 | expect(writes().length).toBe(before); | |
| 169 | }); | |
outdated
Vacuous: it edits the column and reads it back. Connect a second origin without readOnly and assert a write is not refused with origin_read_only. |
||
| 170 | ||
| 171 | test("merging on a mirror is refused outright, with its own plain answer", async () => { | |
| 172 | const merged = await mergePull(app, alice(), repo(), { ...ghost(), originHeadRepo: null } as never, "merge"); | |
| 173 | expect(merged.ok).toBe(false); | |
| 174 | if (!merged.ok) expect(merged.error).toBe("mirrored"); | |
| 175 | }); | |
| 176 | ||
| 177 | test("the REST door answers 409 origin_read_only", async () => { | |
| 178 | const res = await api("/api/v1/repos/alice/vault/issues", { | |
| 179 | method: "POST", | |
| 180 | body: JSON.stringify({ title: "nope" }), | |
| 181 | }); | |
| 182 | expect(res.status).toBe(409); | |
| 183 | const body = (await res.json()) as { error: string; message: string }; | |
| 184 | expect(body.error).toBe("origin_read_only"); | |
| 185 | expect(body.message).not.toContain("origin_read_only"); | |
| 186 | }); | |
| 187 | ||
| 188 | test("an origin connected the ordinary way is not refused as read-only", async () => { | |
| 189 | const state = newFakeState("github", "plain", "plain-secret-77"); | |
| 190 | state.repos.push({ | |
| 191 | id: 10, | |
| 192 | fullName: "plain/vault2", | |
| 193 | cloneUrl: `${originServers.url}/octo/vault.git`, | |
| 194 | defaultBranch: "main", | |
| 195 | htmlUrl: `${originServers.url}/octo/vault`, | |
| 196 | push: true, | |
| 197 | }); | |
| 198 | const plain = serveFakeForge(state); | |
| 199 | try { | |
| 200 | const made = (await ( | |
| 201 | await api("/api/v1/user/origins", { | |
| 202 | method: "POST", | |
| 203 | body: JSON.stringify({ host: plain.url, kind: "github", token: "plain-secret-77" }), | |
| 204 | }) | |
| 205 | ).json()) as { id: string }; | |
| 206 | expect(getOriginById(app.db, made.id)?.readOnly).toBe(false); | |
| 207 | await mirrorAndWait( | |
| 208 | await api(`/api/v1/user/origins/${made.id}/mirror`, { | |
| 209 | method: "POST", | |
| 210 | body: JSON.stringify({ fullName: "plain/vault2" }), | |
| 211 | }), | |
| 212 | ); | |
| 213 | proveAll(app); | |
| 214 | const second = getRepo(app.db, "alice", "vault2")!; | |
| 215 | const result = await openIssueThrough(app, alice(), second, { title: "hello" }); | |
| 216 | if (!result.ok) expect(result.error).not.toBe("origin_read_only"); | |
| 217 | expect(plain.state.requests.some((r) => r.method === "POST")).toBe(true); | |
| 218 | } finally { | |
| 219 | await syncsSettled(); | |
| 220 | plain.stop(); | |
| 221 | } | |
| 222 | }); | |
| 223 | ||
| 224 | test("a row from before the migration is off: the column defaults to 0", () => { | |
| 225 | const col = app.db | |
| 226 | .query<{ name: string; notnull: number; dflt_value: string }, []>("PRAGMA table_info(origin)") | |
| 227 | .all() | |
| 228 | .find((c) => c.name === "read_only")!; | |
| 229 | expect(col.notnull).toBe(1); | |
| 230 | expect(col.dflt_value).toBe("0"); | |
| 231 | }); | |
| 232 | ||
| 233 | test("the web form: readOnly=1 stores the flag, the list shows it, unticked stores none", async () => { | |
| 234 | const cookie = `forge_session=${createSession(app.db, alice().id).token}`; | |
| 235 | const connect = async (login: string, extra: Record<string, string>) => { | |
| 236 | forgetProviders(dirs[0]!); | |
| 237 | const state = newFakeState("forgejo", login, `${login}-tok-123`); | |
| 238 | const f = serveFakeForge(state); | |
| 239 | try { | |
| 240 | const res = await fetch(`${servers.url}/settings/origins/token`, { | |
| 241 | method: "POST", | |
| 242 | headers: { cookie, origin: new URL(servers.url).origin }, | |
| 243 | body: new URLSearchParams({ host: f.url, token: `${login}-tok-123`, ...extra }), | |
| 244 | redirect: "manual", | |
| 245 | }); | |
| 246 | expect(res.status).toBe(303); | |
| 247 | } finally { | |
| 248 | f.stop(); | |
| 249 | } | |
| 250 | return listOrigins(app.db, alice().id).find((o) => o.login === login)!; | |
| 251 | }; | |
| 252 | const ticked = await connect("webro", { readOnly: "1" }); | |
| 253 | expect(ticked.readOnly).toBe(true); | |
| 254 | const unticked = await connect("webrw", {}); | |
| 255 | expect(unticked.readOnly).toBe(false); | |
| 256 | const page = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie } })).text(); | |
| 257 | expect(page).toContain("read-only: nothing is written back"); | |
| 258 | }); | |
| 259 | }); |
test/tui-origins-forge.test.ts +3
| @@ -86,6 +86,8 @@ test("c connects another forge by address and token, and the list says what did | ||
| 86 | 86 | await type(friendToken); |
| 87 | 87 | expect(app.screen.toText()).not.toContain(friendToken); |
| 88 | 88 | await key("enter"); |
| 89 | expect(app.screen.toText()).toContain("Read-only?"); | |
| 90 | await key("enter"); | |
| 89 | 91 | for (let i = 0; i < 50 && listOrigins(mine.db, getUserByName(mine.db, "alice")!.id).length === 0; i++) await Bun.sleep(100); |
| 90 | 92 | if (listOrigins(mine.db, getUserByName(mine.db, "alice")!.id).length === 0) throw new Error(`never came: still waiting while listOrigins(mine.db, getUserByName(mine.db, "alice")!.id).length === 0`); |
| 91 | 93 | await settleApp(app); |
| @@ -128,6 +130,7 @@ test("a token the forge refuses connects nothing and says so", async () => { | ||
| 128 | 130 | await press("enter"); |
| 129 | 131 | await type("forge_not_a_token"); |
| 130 | 132 | await press("enter"); |
| 133 | await press("enter"); | |
| 131 | 134 | for (let i = 0; i < 30 && !app.screen.toText().includes("refused"); i++) { |
| 132 | 135 | await Bun.sleep(100); |
| 133 | 136 | await settleApp(app); |
test/tui-waves.test.ts +1
| @@ -54,6 +54,7 @@ const remoteOf = (src: RepoSource): RepoSource => ({ | ||
| 54 | 54 | authKind: "pat", |
| 55 | 55 | expiresAt: null, |
| 56 | 56 | needsReauth: false, |
| 57 | readOnly: false, | |
| 57 | 58 | software: "", |
| 58 | 59 | }, |
| 59 | 60 | fullName: "someone/else", |
Principle 8: lead with a plain sentence, not the error code. The code travels in the error field.