diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 889fb2f08..8c3215064 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -135,6 +135,10 @@ user-scope directory for machine-local state. load-limits the load check's per-machine limits (stray-minutes, extreme-load), read-only; abcd never creates it (itd-2609231434459890) + credentials.json external credentials by name (a hosting token for + setting up a site), mode 0600, read-only; abcd never + creates it. The interim source the credential store + replaces (itd-2609221017023290) rules.json the machine's rule conventions, the user layer between the bundled domains and each repo's .abcd/rules.json, read-only; abcd never creates it diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index e4207012c..e2d1bbc51 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -41,6 +41,58 @@ production renders from the tag, with the released bytes. | Verb | Bucket | Status | |---|---|---| | `build` | — | shipped | +| `setup` | — | shipped | + +## Setting up a managed repository's site + +Setting up takes a repository abcd manages from the checkout to a live address +(itd-2609061543533170). It refuses a folder abcd does not manage, and works in +three stages, each reported whether or not the next one runs. + +**The repository.** It writes the composition declaration, derived from the +recorded identity block and the documentation's front page (it refuses, naming +the step, where either is missing); the site's static inputs under `site-src/`, +copied from abcd's own; a workflow that renders the site from each published +release with abcd's checksum- and attestation-verified binary and deploys the +rendered archive from a second job; and the provider's host configuration. The +composition and the static inputs are the repository's own once they exist, so +a later run keeps them as they are. The workflow and the host configuration are +abcd's: a copy that differs refuses the whole run, with nothing written and +nothing remote attempted, unless the run is told to replace it. + +**The forge.** Two deployment environments, one for the render and one for the +deploy, each admitting only the default branch and release tags, created +through the forge's API as the person running the verb. An environment that +already exists is never rewritten, because the forge's environment write +replaces its whole protection set, required reviewers included: one on named +rules and no rule beyond those two gains the rules it lacks, and one that +admits more, through its protection mode or a rule of its own such as branch +`*`, is reported `unrestricted`, with restricting it named as a step ahead of +any secret step. + +**The host.** With a hosting credential on this machine, the provider adapter +creates the host, routes the custom domain to it and reports the live address. +Without one, the stage stops and says what remains: store the credential and +re-run, or create the host in the provider's console. + +Both remote stages write only after a confirmation that names each change, and +an unanswered run declines them. The deploy environment's secrets are never +set by abcd, because the value would pass through it: the verb reads which +secret names are present and prints the exact command for each one that is not. +The credential is read by name from the machine and never written into the +repository or the report. A second run over an unchanged repository and host +writes nothing and says so. One provider ships, behind an adapter seam +([`05-internals/02-adapters.md`](../05-internals/02-adapters.md#hosting-providers)). + +## The page set + +Every repository gets the same pages: the landing page, the record explorer, +one page per record, the relationship graph, the timeline, the glossary and the +status page. The composition declaration's `pages` block switches pages off and +cannot add one. A page switched off takes its navigation entry and every link to +it with it, and switching the explorer off takes every explorer page. The landing +page and the record pages carry the site, so a declaration switching either off +beneath the explorer is refused. ## The single-source rule @@ -197,7 +249,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd site` -Sub-verbs: `abcd site build`. +Sub-verbs: `abcd site build`, `abcd site setup`. | Flag | Type | |---|---| @@ -215,4 +267,15 @@ Sub-verbs: none. | `--preview` | bool | | `--version` | string | +### `abcd site setup` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--confirm` | bool | +| `--domain` | string | +| `--name` | string | +| `--yes` | bool | + diff --git a/.abcd/development/brief/05-internals/02-adapters.md b/.abcd/development/brief/05-internals/02-adapters.md index 799899413..8d2880abb 100644 --- a/.abcd/development/brief/05-internals/02-adapters.md +++ b/.abcd/development/brief/05-internals/02-adapters.md @@ -70,6 +70,25 @@ workspace's root-path field, and matches against `git rev-parse --show-toplevel` `~/`-relative path normalisation. Source layout: `~/Library/Application Support/RepoPrompt/Workspaces//workspace.json`. +## Hosting providers + +`abcd site setup` routes a rendered site through a provider seam, +`internal/adapter/hosting`, which is not one of the five capability seams: no +dropped dependency stands behind it, and there is no native default, because a +site is served by somebody. An adapter has two halves. The repository half is +data — the deploy step, the secret names that step reads, and the host +configuration file it deploys from — rendered into files the verb writes. The +host half connects with the person's credential and creates the host, routes a +domain to it and reports the address, after a read that writes nothing. + +One provider ships: an assets-only Cloudflare Worker, the host abcd's own site +uses. A second is one implementation of the interface and one entry in the +site package's provider list; the verb does not change. The credential is +resolved by name through `internal/core/credential`, whose interim source is +`~/.abcd/credentials.json` until the credential store (itd-2609221017023290) +replaces it; the connected adapter holds it, and it is scrubbed from every host +message before one can reach an error. + ## Lifeboat source readers Disembark reads a repo's **own settled artefacts** into the lifeboat through a set diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index fea5cbb08..091d1eadd 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -251,6 +251,9 @@ staged worktree store, the run state an autonomous run's sessions share in the binary resolves the repo-scope `.abcd/config.json`, and no home-scope one is read at all; the one machine setting read today is `load-limits`, the load check's two limits, read-only and never created, itd-2609231434459890), the +external credentials adapters resolve by name in `credentials.json` (read-only, +never created, refused unless it is a regular file this uid owns at mode 0600 — +the interim source the credential store, itd-2609221017023290, replaces), the machine's rule conventions in `rules.json` (the user layer of the rules loader, read-only and never created, itd-117 — see [the rules layers](#the-rules-layers--bundled-user-repo) below), user-scope memory for personal cross-project knowledge (a later diff --git a/.abcd/development/brief/05-internals/10-site.md b/.abcd/development/brief/05-internals/10-site.md index 065f202b3..4aff60ed2 100644 --- a/.abcd/development/brief/05-internals/10-site.md +++ b/.abcd/development/brief/05-internals/10-site.md @@ -51,8 +51,9 @@ intents: the body verbatim, typed links phrased from that record's own side, and the forge links to the file and to its commit history), `/record/graph/` (the chart's stage and its list twin, driven by `site-src/record.js`, reading - `?focus=`), `/record/timeline/` (the five-lane genealogy as one static SVG - emitted in Go), `/record/foundations/` (principles and disciplines as cards + `?focus=`), the genealogy (one static SVG emitted in Go, folded shut into + the `/record/` dashboard rather than served as a page of its own), + `/record/foundations/` (principles and disciplines as cards that list and link), `/contributors/` and `/references/`. The bibliography is rendered by a stdlib CSL-JSON formatter and numbered identically to `ACKNOWLEDGEMENTS.md`, with a build check that the two agree entry for entry. @@ -79,6 +80,16 @@ intents: audit is CI's optional, non-gating job; static and rendered gates are complementary, and the audit's first run caught an overflow the static gate cannot see. +- **`abcd site setup`** lays the site into a repository abcd manages: the + composition derived from the identity block and the documentation, the + static inputs seeded from abcd's own `site-src/` (byte copies, held equal by + a test), a render-on-release workflow whose action pins follow abcd's own + site workflow by test, and the provider's host configuration — written + through the launch scaffold's shared writer. The forge environments go + through `gh` as the invoking person, and the host through the hosting + adapter seam (`internal/adapter/hosting`, one provider). The manifest's + `pages` block is the closed page set's switches, resolved once and consulted + by the explorer wherever it adds a page, a tab or a link. - **The generic/specific boundary** of the verb family is governed by the itd-140 discipline: repo-agnostic input contract, genericity demonstrated on a sparse second instance before it is claimed, working-tier ledger diff --git a/.abcd/development/intents/planned/itd-2609061543533170-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md b/.abcd/development/intents/shipped/itd-2609061543533170-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md similarity index 56% rename from .abcd/development/intents/planned/itd-2609061543533170-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md rename to .abcd/development/intents/shipped/itd-2609061543533170-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md index 4faf3b7c1..108c0b313 100644 --- a/.abcd/development/intents/planned/itd-2609061543533170-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md +++ b/.abcd/development/intents/shipped/itd-2609061543533170-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md @@ -58,6 +58,17 @@ Ruled by the product thinker on 2026-09-21, in the interview that filed and plan 2. The provider is an adapter behind a seam, one shipped. 3. The same pages for every repository, opt-out per page. +Taken in the implementing lane (autonomous run A, 2026-09-26), within the rulings above: + +4. **The credential's interim source (2026-09-26).** The credential store this intent resolves through is itd-2609221017023290, planned and not built. Until it lands, the hosting credential is read by name through one narrow interface (`internal/core/credential`, `Resolve(name)`) from one machine-scoped file, `~/.abcd/credentials.json`, refused unless it is a regular file this uid owns at mode 0600 or tighter. itd-2609221017023290 is the successor: it replaces the source behind the interface, and no reader changes. +5. **Secrets are the person's step (2026-09-26).** The forge encrypts an environment secret before it accepts it, and doing that here would add a dependency and pass the value through abcd. The verb reads which secret names the deploy environment holds and prints the exact `gh secret set` command for each missing one. +6. **Render on release, whoever made it (2026-09-26).** The workflow runs on `release: published`, on the `release` workflow completing on the default branch (a release created with the workflow's own token fires no release event), and on dispatch. It renders with abcd's latest release, checksum- and attestation-verified, so the file does not change when abcd does. + + *Addendum (2026-09-26, the lane's security review, iss-2609260120384106).* As first written the workflow never fired by itself on a repository `launch scaffold` laid out: `auto-release` runs `release` by `workflow_call`, inside its own run, so no run named `release` completes; a hand-pushed tag's `release` run has the tag as its head branch, which the trigger's branch filter dropped; and the token-made release fires no `release: published`. The `workflow_run` entry therefore names both `release` and `auto-release` and filters no branch, and the render job's gate admits a head branch that is the default branch or starts with `v`, beside the fork and pull-request conditions it already held. `auto-release` completes on every push to the default branch, so a push that released nothing redeploys the latest published release: the bytes the site already serves, unless a dispatch rolled it back to an older tag, which that push replaces with the latest release again. +7. **The page set's edges (2026-09-26).** The status page is the record health page; the timeline is the genealogy the dashboard carries; the landing page and the record pages cannot be switched off beneath the explorer. The composition setup derives quotes the recorded identity block and composes the landing page from `docs/README.md`, and the static inputs are seeded as byte copies of abcd's own. +8. **The account is the token's (2026-09-26).** The provider account is the one the credential reaches; a token reaching none or several is refused rather than guessed at, so no account identifier is configured anywhere. +9. **The supply-chain posture and the exit-1 output, named (2026-09-26, the lane's security review).** Accepted as they stand, and named here so no reader has to infer them. The render job takes abcd's latest release, bound to `intentdriven/abcd`'s release workflow by an attestation check; the checksums ship in the same release, so they prove transfer integrity only. A compromised abcd release therefore runs in a job holding a read-only token and can deface the site, and cannot reach the deploy secrets, because the deploy job runs the provider's tool and not abcd. That tool, wrangler, is installed from npm at a named version with no integrity pin, as in abcd's own site workflow. The environments admit tags `v*` and setup checks no tag ruleset, so the right to push a tag is enough to run modified workflow content in `site`. A `--json` run that exits 1 prints two JSON documents, the result and then the CLI's error envelope; that is the repository-wide convention, and a strict consumer of this verb's `--json` reads both. + ## Open Questions _None open._ @@ -73,7 +84,8 @@ _None open._ ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-070279698e5f). ## Grounds diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 250c0178c..22ab63e31 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -2011,6 +2011,41 @@ "moved_to": "abcd lint site", "flags": [] }, + { + "path": "abcd site setup", + "hidden": false, + "sentence": "Take the website from this checkout to a live address: Writes its files, and the forge and host changes once confirmed; refuses a folder abcd does not manage.", + "flags": [ + { + "name": "confirm", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "domain", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "name", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "yes", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd spec", "hidden": false, diff --git a/.abcd/development/specs/open/spc-2609212141407459-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md b/.abcd/development/specs/closed/spc-2609212141407459-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md similarity index 100% rename from .abcd/development/specs/open/spc-2609212141407459-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md rename to .abcd/development/specs/closed/spc-2609212141407459-abcd-sets-up-a-managed-repository-s-release-rendered-site-en.md diff --git a/.abcd/work/issues/open/iss-2609260120380520-the-machine-credential-file-internal-core-credential-abcd.md b/.abcd/work/issues/open/iss-2609260120380520-the-machine-credential-file-internal-core-credential-abcd.md new file mode 100644 index 000000000..9d3fbc20c --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260120380520-the-machine-credential-file-internal-core-credential-abcd.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2609260120380520" +slug: "the-machine-credential-file-internal-core-credential-abcd" +severity: "minor" +category: "security" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane sitesetup)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/credential/credential.go" +deferred_after: "v0.10.0" +deferral_reason: "deferred to the integration step (run A, 2026-09-26): the strict duplicate-key decoder jsonstrict lives on the unmerged lintB lane and copying it here would fork it; once lintB lands, credential.go reroutes its decode through jsonstrict and this record is resolved there" +--- + +The machine credential file (internal/core/credential, ~/.abcd/credentials.json) is decoded with plain json.Unmarshal, so a duplicate key silently takes the last value instead of being refused; the strict duplicate-key decoder (jsonstrict) lives on an unmerged lane, not on this base. diff --git a/.abcd/work/issues/open/iss-2609260927214634-site-setup-default-branch-not-from-forge.md b/.abcd/work/issues/open/iss-2609260927214634-site-setup-default-branch-not-from-forge.md new file mode 100644 index 000000000..f337d0a13 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260927214634-site-setup-default-branch-not-from-forge.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260927214634" +slug: "site-setup-default-branch-not-from-forge" +severity: "minor" +category: "drift" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review3-sitesetup note (f)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/launch/scaffold/scaffold.go" +--- + +site setup compares the environment's deployment branch rules against a default branch derived by scaffold.deriveBranch (origin/HEAD, else the checked-out HEAD, else main), not the forge's default branch, so on a checkout with no origin/HEAD run from a feature branch a correctly restricted environment is told to remove its rule for main. The advice is wrong but nothing is written; the spec's 'default-branch name read from the forge' does not describe the code. diff --git a/.abcd/work/issues/open/iss-2609260927217168-site-setup-policy-read-unpaginated.md b/.abcd/work/issues/open/iss-2609260927217168-site-setup-policy-read-unpaginated.md new file mode 100644 index 000000000..6be12455b --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260927217168-site-setup-policy-read-unpaginated.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260927217168" +slug: "site-setup-policy-read-unpaginated" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review3-sitesetup note (e)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/forge.go" +--- + +site setup reads an environment's deployment branch policies with per_page=100 and no pagination (forge.go:112), so a broad branch rule past the 100th policy is invisible to the restriction check and the environment can read as restricted when it is not. Reaching it needs an admin who already put over 100 rules on one environment; the environments and secrets reads at :84 and :145 share the shape. diff --git a/.abcd/work/issues/open/iss-2609260928152365-site-header-links-docs-without-a-docs-build.md b/.abcd/work/issues/open/iss-2609260928152365-site-header-links-docs-without-a-docs-build.md new file mode 100644 index 000000000..d66cfa85c --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260928152365-site-header-links-docs-without-a-docs-build.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260928152365" +slug: "site-header-links-docs-without-a-docs-build" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: sitesetup lane report risk note" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/compose.go" +--- + +The site header always links /docs/ (compose.go:334), so a managed repository set up with site setup and no docs build serves a navigation link that 404s on every page. The renderer's page shapes were out of the sitesetup lane's scope; the link should follow whether a docs tree is rendered. diff --git a/.abcd/work/issues/resolved/iss-2609252311550527-the-site-internals-chapter-brief-05-internals-10-site-md.md b/.abcd/work/issues/resolved/iss-2609252311550527-the-site-internals-chapter-brief-05-internals-10-site-md.md new file mode 100644 index 000000000..866d5ba8f --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609252311550527-the-site-internals-chapter-brief-05-internals-10-site-md.md @@ -0,0 +1,21 @@ +--- +schema_version: 1 +id: "iss-2609252311550527" +slug: "the-site-internals-chapter-brief-05-internals-10-site-md" +severity: "minor" +category: "drift" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +resolution: "The internals chapter now places the genealogy in the dashboard, and the dead timeline page code is removed." +impact: internal +resolved_by: + commit: "501e0896b04eb1233fe2e3eafbeb803b6f1e9fc8" +--- + +The site internals chapter (brief 05-internals/10-site.md) lists /record/timeline/ as a page the explorer serves, but the build never emits it: the genealogy renders folded into the /record/ dashboard, and timelinePage in internal/core/site/timeline.go is unreachable code. + +## Grounds + +- pursued: the chapter names only pages the build writes; shown wrong if a build emits a page the chapter omits or the chapter names one no build emits diff --git a/.abcd/work/issues/resolved/iss-2609260120380835-when-abcd-site-setup-s-forge-stage-is-refused-gh-absent-or.md b/.abcd/work/issues/resolved/iss-2609260120380835-when-abcd-site-setup-s-forge-stage-is-refused-gh-absent-or.md new file mode 100644 index 000000000..2719990be --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609260120380835-when-abcd-site-setup-s-forge-stage-is-refused-gh-absent-or.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609260120380835" +slug: "when-abcd-site-setup-s-forge-stage-is-refused-gh-absent-or" +severity: "minor" +category: "security" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane sitesetup)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/setup.go" +resolution: "A refused forge stage prints the create-the-environments step ahead of the secret steps, as the unreachable path does. Impact internal: the verb is unreleased and ships with itd-2609061543533170." +impact: internal +resolved_by: + spec: "spc-2609212141407459" + commit: "7247b6cc" +--- + +When abcd site setup's forge stage is refused (gh absent or unauthenticated, or a forge call failing) it prints the gh secret set steps for the site environment but no step to create the environments, so a person following the steps sets the token and the first workflow run auto-creates site with no deployment policy, deployable from any branch. + +## Grounds + +- pursued: a person following the remaining steps of any refused or unreachable run protects the environment before setting a secret; a remaining list with a secret step and no environment step ahead of it would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609260120383554-abcd-site-setup-rewrites-an-existing-deployment-environment.md b/.abcd/work/issues/resolved/iss-2609260120383554-abcd-site-setup-rewrites-an-existing-deployment-environment.md new file mode 100644 index 000000000..5ab8c3628 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609260120383554-abcd-site-setup-rewrites-an-existing-deployment-environment.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609260120383554" +slug: "abcd-site-setup-rewrites-an-existing-deployment-environment" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane sitesetup)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/setup.go" +resolution: "Setup writes the forge's environment endpoint only for an absent environment; an existing one gains missing policies through their own endpoint or, when it admits more refs, is reported unrestricted with the restriction as a remaining step. Impact internal: the verb is unreleased and ships with itd-2609061543533170." +impact: internal +resolved_by: + spec: "spc-2609212141407459" + commit: "ab227d68" +--- + +abcd site setup rewrites an existing deployment environment through PUT /repos/{r}/environments/{env} with only deployment_branch_policy in the body, and that endpoint replaces the whole protection set, so a site environment carrying required reviewers, a wait timer or prevent_self_review loses them after a confirmation that names only a branch restriction (setup.go plan.put, forge.go PutEnvironment). + +## Grounds + +- pursued: an existing environment's reviewers and wait timer survive any setup run; a forge write to the environment endpoint for an environment that already exists would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609260120384106-the-site-workflow-abcd-site-setup-writes-never-fires-by.md b/.abcd/work/issues/resolved/iss-2609260120384106-the-site-workflow-abcd-site-setup-writes-never-fires-by.md new file mode 100644 index 000000000..9125201c2 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609260120384106-the-site-workflow-abcd-site-setup-writes-never-fires-by.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609260120384106" +slug: "the-site-workflow-abcd-site-setup-writes-never-fires-by" +severity: "major" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane sitesetup)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/setupsrc/site.yml.tmpl" +resolution: "The site workflow listens for both release and auto-release with no branch filter in the trigger, and its render gate admits the default branch or a v-tag head branch beside the fork and pull-request conditions. Impact internal: the verb is unreleased and ships with itd-2609061543533170." +impact: internal +resolved_by: + spec: "spc-2609212141407459" + commit: "3e23f26c" +--- + +The site workflow abcd site setup writes never fires by itself on a repository launch scaffold laid out: auto-release runs release.yml by workflow_call so no run named release exists for workflow_run, a tag-push release run has the tag as head_branch which the branches filter drops, and a GITHUB_TOKEN-made release fires no release:published, so only a hand dispatch deploys while the verb says the site deploys on the next published release. + +## Grounds + +- pursued: a release cut by the scaffolded auto-release or by a hand-pushed tag starts the site workflow with no dispatch; a first real release on a scaffolded repository that leaves the site workflow unrun would show it wrong (not yet run on Actions) diff --git a/.abcd/work/issues/resolved/iss-2609260856423957-abcd-site-setup-reports-an-existing-environment-on-custom.md b/.abcd/work/issues/resolved/iss-2609260856423957-abcd-site-setup-reports-an-existing-environment-on-custom.md new file mode 100644 index 000000000..b5b4d1931 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609260856423957-abcd-site-setup-reports-an-existing-environment-on-custom.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609260856423957" +slug: "abcd-site-setup-reports-an-existing-environment-on-custom" +severity: "minor" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/setup.go" +resolution: "site setup reads an existing environment's custom rules for extra ones too: a rule beyond the default branch and tags v* makes it unrestricted, with a step naming the rules to remove, and setup writes nothing to it" +impact: internal +resolved_by: + commit: "c13c3e94" +--- + +abcd site setup reports an existing environment on custom deployment policies as current, or adds the missing rules and reports it written, with no restriction step, even when it also holds a rule broader than the default branch and tags v* (a branch policy `*`, for example). setupEnvironments marks an environment unrestricted only when it is not on custom policies or admits protected branches; it reads the existing policies only to find the missing ones, never to find the extra ones, so an environment that admits every branch through a custom rule passes as restricted and the deploy environment's secrets can be reached from unreviewed workflow content. + +## Grounds + +- pursued: an environment holding any rule beyond branch and tag v* is reported unrestricted with a restriction step and receives no forge write; a run against such an environment that reports it current or written, or writes a policy to it, would show it wrong diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md index 566c0792e..d4a621c98 100644 --- a/ACKNOWLEDGEMENTS.md +++ b/ACKNOWLEDGEMENTS.md @@ -109,6 +109,11 @@ Ideas and methodologies that shaped the design — not code abcd depends on. outright instead of shipped, and the enforcing control sits at the execution layer. +- **Cloudflare Workers and its v4 API (Cloudflare)** — the host the one + provider behind `abcd site setup`'s hosting seam targets: an assets-only + Worker deployed by the pinned `wrangler-action`, created, routed and + addressed through the Workers, domains and subdomain endpoints + (itd-2609061543533170). - **Conftest (Open Policy Agent)** — the severity→exit-code convention (`0` clean / `1` warnings / `2` any error) the `abcd lint` verb adopts for its tri-state exit, taken as vocabulary without adopting the Rego engine (itd-85). diff --git a/commands/site.md b/commands/site.md index 54af4ee11..96b6b71e6 100644 --- a/commands/site.md +++ b/commands/site.md @@ -1,7 +1,7 @@ --- name: site description: "Report what the website declares and what was built: Writes nothing; refuses any argument." -argument-hint: "[build]" +argument-hint: "[build|setup]" block: agents --- @@ -86,6 +86,53 @@ rendered subset is reported as `file:line`, and so is an image the page names that the repository does not carry. Neither is a rendering bug to work around — the fix is an edit to the page. +## `setup` — take a managed repository's site to a live address + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" site setup --json +``` + +sets up the site of a repository abcd manages, in three stages, and emits +`{ "status": …, "files": […], "environments": […], "host": {…}, "remaining": […], "notes": […] }`: + +- `files` — the repository half, each `written`, `current`, `kept` or + `refused`: `.abcd/site.json` (derived from the identity block and + `docs/README.md`), the static inputs under `site-src/`, the workflow + `.github/workflows/site.yml` (render on each published release with abcd's + verified binary, deploy from the rendered archive) and `wrangler.jsonc`. The + composition and the static inputs are the repository's own once they exist + and are `kept`; a workflow or host configuration that differs from what setup + writes is `refused`, the whole run writes nothing, and `--confirm` replaces it. +- `environments` — the forge's `site-render` and `site` deployment + environments, each admitting only the default branch and tags `v*`, created + through `gh` as you. An existing environment is never rewritten (the forge's + write would replace its required reviewers): one on named rules and no rule + beyond those two gains the rules it lacks, and one that admits more, through + its protection mode or a rule of its own such as branch `*`, is + `unrestricted`, with restricting it listed in `remaining`. +- `host` — with a hosting credential stored on this machine, the host is + created and the domain routed to it, and `address` is the live address. + Without one, `status` is `no_credential` and nothing is contacted. +- `remaining` — the exact steps left for you: committing the written files, + storing the credential, and one `gh secret set … --env site` command for + each deploy secret the environment does not hold yet. abcd never reads or + sets a secret's value. + +Both remote stages ask before they write, naming each change. An unanswered +run declines them and exits `1`; `--yes` confirms in advance — pass it only +when the user has asked for the forge and host changes. A second run over an +unchanged repository reports `no_change` and writes nothing. + +The first run names the host after the repository; `--name` and `--domain` +choose the host name and the custom domain, and are recorded in the +composition's `hosting` block. The composition's `pages` block switches pages of +the closed set (`landing`, `explorer`, `record_pages`, `graph`, `timeline`, +`glossary`, `status`) off; it cannot add one. + +Report each stage's outcome, then the remaining steps in order. Never paste a +credential into the conversation: the store is the file `~/.abcd/credentials.json` +(mode `0600`), which the user writes themselves. + ## The gate over what was rendered Whether a build may be published is `/abcd:lint site` — run `abcd lint site diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index cf4a24abc..f31927b92 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1598,6 +1598,21 @@ Render the website into the output directory: Writes only inside that directory; --version string version for the footer and the build stamp (default: the newest dated CHANGELOG heading) ``` +#### `abcd site setup` + +Take the website from this checkout to a live address: Writes its files, and the forge and host changes once confirmed; refuses a folder abcd does not manage. + +**Usage:** `abcd site setup [flags]` + +**Flags:** + +``` + --confirm replace a workflow or host configuration that differs from what setup writes + --domain string custom domain to route to the host when the composition names none + --name string host name when the composition names none (default: the repository's name) + --yes confirm the forge and host changes without being asked; without it an unanswered run declines them +``` + ### `abcd spec` Render the spec store's status: Writes nothing; refuses outside a git checkout. diff --git a/internal/adapter/hosting/cloudflare/cloudflare.go b/internal/adapter/hosting/cloudflare/cloudflare.go new file mode 100644 index 000000000..57f43e7e9 --- /dev/null +++ b/internal/adapter/hosting/cloudflare/cloudflare.go @@ -0,0 +1,419 @@ +// Package cloudflare is the one hosting provider abcd ships +// (itd-2609061543533170): an assets-only Cloudflare Worker, the host abcd's own +// site is served from. +// +// The repository half is data: wrangler.jsonc, and a deploy step that runs the +// pinned wrangler action with the two environment secrets it names. The host +// half speaks the Cloudflare v4 API over HTTPS with the person's API token: +// +// GET /accounts the one account the token reaches +// GET /accounts/{a}/workers/workers does the Worker exist (paged) +// POST /accounts/{a}/workers/workers create it, workers.dev on +// GET /accounts/{a}/workers/domains is the domain routed, and to whom +// PUT /accounts/{a}/workers/domains route the domain to the Worker +// GET /accounts/{a}/workers/subdomain the workers.dev address +// +// These calls are written against Cloudflare's published API reference and are +// exercised in this repository only against cloudflaretest's in-process fake; +// none of them has been run against the live service by its tests. +// +// The token is held by the connected client and set on one request header. It +// is never formatted into an error: a host's error message is remote-controlled +// text, so it is truncated and has the token scrubbed out of it before it is +// allowed into a returned error, which callers print. +package cloudflare + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/intentdriven/abcd/internal/adapter/hosting" +) + +// ProductionBaseURL is the Cloudflare v4 API. +const ProductionBaseURL = "https://api.cloudflare.com/client/v4" + +// requestTimeout bounds one API call. +const requestTimeout = 30 * time.Second + +// maxResponseBytes bounds what one response may put into memory. +const maxResponseBytes = 4 << 20 + +// maxPages bounds the Worker listing's pagination. +const maxPages = 50 + +// maxHostMessage bounds how much of a host's error message reaches an error. +const maxHostMessage = 200 + +// wranglerAction and wranglerVersion are the deploy step's pins. They are the +// pins abcd's own site workflow deploys with; TestTheDeployPinsFollowAbcdsOwn +// holds the two in step. +const ( + wranglerAction = "cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0" + wranglerVersion = "4.123.0" + // compatibilityDate is the Workers runtime date the host configuration + // declares. An assets-only Worker runs no script, so the date selects no + // behaviour the site depends on; it is fixed so the file is a function of + // the site alone and a re-run writes nothing. + compatibilityDate = "2026-08-19" +) + +// Secret names the deploy job reads. The account id is not secret, but it rides +// as one so the workflow names nothing account-specific in the tree. +const ( + SecretToken = "CLOUDFLARE_API_TOKEN" + SecretAccount = "CLOUDFLARE_ACCOUNT_ID" +) + +// CredentialName is the machine credential this provider resolves. +const CredentialName = "hosting.cloudflare" + +// Adapter is the cloudflare provider. The zero value speaks to the production +// API; BaseURL points it at a fake. +type Adapter struct { + BaseURL string +} + +var _ hosting.Adapter = Adapter{} + +// Name is the manifest key. +func (Adapter) Name() string { return "cloudflare" } + +// CredentialName is the name the API token is resolved by. +func (Adapter) CredentialName() string { return CredentialName } + +// Secrets are the deploy environment's secret names. +func (Adapter) Secrets() []string { return []string{SecretToken, SecretAccount} } + +// DeployStep runs `wrangler deploy` through the pinned action. `deploy` also +// asserts the routes wrangler.jsonc declares, so a lost domain attachment is +// re-claimed by the next release rather than staying lost. +func (Adapter) DeployStep() string { + return ` - name: Deploy to Cloudflare + uses: ` + wranglerAction + ` + with: + apiToken: ${{ secrets.` + SecretToken + ` }} + accountId: ${{ secrets.` + SecretAccount + ` }} + command: deploy + wranglerVersion: '` + wranglerVersion + `' +` +} + +// HostConfig renders wrangler.jsonc for s. The values are validated by the +// caller; they are JSON-encoded here regardless, so no value can break out of +// its string. +func (Adapter) HostConfig(s hosting.Site) (string, []byte) { + q := func(v string) string { b, _ := json.Marshal(v); return string(b) } + var b strings.Builder + b.WriteString("{\n") + b.WriteString(" // Written by `abcd site setup`. An assets-only Worker serving the site the\n") + b.WriteString(" // release workflow renders (.github/workflows/site.yml); the deploy job runs\n") + b.WriteString(" // `wrangler deploy`, which also asserts the routes below. Re-running the\n") + b.WriteString(" // verb rewrites nothing while this file is unchanged, and refuses a hand edit.\n") + b.WriteString(` "name": ` + q(s.Name) + ",\n") + b.WriteString(` "compatibility_date": ` + q(compatibilityDate) + ",\n") + if s.Domain != "" { + b.WriteString(` "routes": [` + "\n") + b.WriteString(` { "pattern": ` + q(s.Domain) + `, "custom_domain": true }` + "\n") + b.WriteString(" ],\n") + } + b.WriteString(` "assets": {` + "\n") + b.WriteString(` "directory": "./site",` + "\n") + b.WriteString(` "not_found_handling": "404-page"` + "\n") + b.WriteString(" }\n") + b.WriteString("}\n") + return "wrangler.jsonc", []byte(b.String()) +} + +// Connect returns the host half acting with token. +func (a Adapter) Connect(token string) hosting.Provider { + base := a.BaseURL + if base == "" { + base = ProductionBaseURL + } + return &client{ + base: strings.TrimRight(base, "/"), + token: token, + http: &http.Client{ + Timeout: requestTimeout, + // A redirect would carry the request to an address the base did not + // name. The API does not redirect, so one is refused rather than + // followed. + CheckRedirect: func(*http.Request, []*http.Request) error { return errRedirect }, + }, + } +} + +var errRedirect = errors.New("the host answered with a redirect, which is not followed") + +// nameRe and domainRe re-check what the caller validated: the name becomes a +// path segment and a body field, the domain a query value and a body field. +var ( + nameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`) + accountRe = regexp.MustCompile(`^[A-Za-z0-9]{1,64}$`) + subRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) +) + +func checkSite(s hosting.Site) error { + if !nameRe.MatchString(s.Name) { + return errors.New("cloudflare: the host name is not a plain Worker name, so it will not be sent") + } + if s.Domain != "" && !domainRe.MatchString(s.Domain) { + return errors.New("cloudflare: the domain is not a plain domain name, so it will not be sent") + } + return nil +} + +type client struct { + base string + token string + http *http.Client + account string +} + +type envelope struct { + Success bool `json:"success"` + Errors []apiMessage `json:"errors"` + Result json.RawMessage `json:"result"` + Info struct { + Page int `json:"page"` + TotalPages int `json:"total_pages"` + } `json:"result_info"` +} + +type apiMessage struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// call makes one request. what names the route in an error (never the token, +// never a query value). +func (c *client) call(ctx context.Context, method, what, path string, query url.Values, body any) (envelope, error) { + var rd io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return envelope{}, err + } + rd = bytes.NewReader(raw) + } + u := c.base + path + if len(query) > 0 { + u += "?" + query.Encode() + } + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, method, u, rd) + if err != nil { + return envelope{}, fmt.Errorf("cloudflare: %s: the request could not be built", what) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + if errors.Is(err, errRedirect) { + return envelope{}, fmt.Errorf("cloudflare: %s: %v", what, errRedirect) + } + // The transport error names the URL, which holds no credential; the + // scrub is belt and braces. + return envelope{}, fmt.Errorf("cloudflare: %s: %s", what, c.scrub(err.Error())) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return envelope{}, fmt.Errorf("cloudflare: %s: the response could not be read", what) + } + var env envelope + jerr := json.Unmarshal(raw, &env) + if resp.StatusCode < 200 || resp.StatusCode > 299 || jerr != nil || !env.Success { + return envelope{}, fmt.Errorf("cloudflare: %s: HTTP %d%s", what, resp.StatusCode, c.messages(env.Errors)) + } + return env, nil +} + +// messages renders the host's error messages, bounded and scrubbed. +func (c *client) messages(ms []apiMessage) string { + if len(ms) == 0 { + return "" + } + var parts []string + for _, m := range ms { + parts = append(parts, strconv.Itoa(m.Code)+" "+m.Message) + } + s := c.scrub(strings.Join(parts, "; ")) + if len(s) > maxHostMessage { + s = s[:maxHostMessage] + "…" + } + return " (" + s + ")" +} + +// scrub removes the token from text a host or a transport produced. +func (c *client) scrub(s string) string { + if c.token == "" { + return s + } + return strings.ReplaceAll(s, c.token, "[credential]") +} + +// accountID resolves the one account the token reaches, once. +func (c *client) accountID(ctx context.Context) (string, error) { + if c.account != "" { + return c.account, nil + } + env, err := c.call(ctx, http.MethodGet, "list accounts", "/accounts", url.Values{"per_page": {"50"}}, nil) + if err != nil { + return "", err + } + var accounts []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(env.Result, &accounts); err != nil { + return "", errors.New("cloudflare: list accounts: the result is not a list of accounts") + } + switch len(accounts) { + case 0: + return "", errors.New("cloudflare: the credential reaches no account, so there is nowhere to create the site") + case 1: + default: + return "", fmt.Errorf("cloudflare: the credential reaches %d accounts; abcd will not pick one, so scope the token to the account that should host the site", len(accounts)) + } + if !accountRe.MatchString(accounts[0].ID) { + return "", errors.New("cloudflare: the account id the host returned is not a plain identifier") + } + c.account = accounts[0].ID + return c.account, nil +} + +// Inspect reads whether the Worker exists and whether the domain routes to it. +func (c *client) Inspect(ctx context.Context, s hosting.Site) (hosting.State, error) { + if err := checkSite(s); err != nil { + return hosting.State{}, err + } + acct, err := c.accountID(ctx) + if err != nil { + return hosting.State{}, err + } + var st hosting.State + for page := 1; page <= maxPages; page++ { + env, err := c.call(ctx, http.MethodGet, "list Workers", "/accounts/"+url.PathEscape(acct)+"/workers/workers", + url.Values{"per_page": {"100"}, "page": {strconv.Itoa(page)}}, nil) + if err != nil { + return hosting.State{}, err + } + var workers []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(env.Result, &workers); err != nil { + return hosting.State{}, errors.New("cloudflare: list Workers: the result is not a list of Workers") + } + for _, w := range workers { + if w.Name == s.Name { + st.Exists = true + } + } + if st.Exists || env.Info.TotalPages <= page { + break + } + } + if s.Domain == "" { + st.Routed = true + return st, nil + } + env, err := c.call(ctx, http.MethodGet, "list Worker domains", "/accounts/"+url.PathEscape(acct)+"/workers/domains", + url.Values{"hostname": {s.Domain}}, nil) + if err != nil { + return hosting.State{}, err + } + var domains []struct { + Hostname string `json:"hostname"` + Service string `json:"service"` + } + if err := json.Unmarshal(env.Result, &domains); err != nil { + return hosting.State{}, errors.New("cloudflare: list Worker domains: the result is not a list of domains") + } + for _, d := range domains { + if d.Hostname != s.Domain { + continue + } + if d.Service != s.Name { + svc := d.Service + if !nameRe.MatchString(svc) { + svc = "a Worker with an unprintable name" + } + return hosting.State{}, fmt.Errorf("cloudflare: %s is already routed to %s; abcd will not move a live domain off another Worker", s.Domain, svc) + } + st.Routed = true + } + return st, nil +} + +// Create creates the Worker with its workers.dev address on. +func (c *client) Create(ctx context.Context, s hosting.Site) error { + if err := checkSite(s); err != nil { + return err + } + acct, err := c.accountID(ctx) + if err != nil { + return err + } + _, err = c.call(ctx, http.MethodPost, "create Worker", "/accounts/"+url.PathEscape(acct)+"/workers/workers", nil, + map[string]any{"name": s.Name, "subdomain": map[string]bool{"enabled": true}}) + return err +} + +// Route attaches the custom domain to the Worker. +func (c *client) Route(ctx context.Context, s hosting.Site) error { + if err := checkSite(s); err != nil { + return err + } + if s.Domain == "" { + return nil + } + acct, err := c.accountID(ctx) + if err != nil { + return err + } + _, err = c.call(ctx, http.MethodPut, "route domain", "/accounts/"+url.PathEscape(acct)+"/workers/domains", nil, + map[string]string{"hostname": s.Domain, "service": s.Name}) + return err +} + +// Address is the custom domain when there is one, the workers.dev name +// otherwise. +func (c *client) Address(ctx context.Context, s hosting.Site) (string, error) { + if err := checkSite(s); err != nil { + return "", err + } + if s.Domain != "" { + return "https://" + s.Domain, nil + } + acct, err := c.accountID(ctx) + if err != nil { + return "", err + } + env, err := c.call(ctx, http.MethodGet, "read workers.dev subdomain", "/accounts/"+url.PathEscape(acct)+"/workers/subdomain", nil, nil) + if err != nil { + return "", err + } + var sub struct { + Subdomain string `json:"subdomain"` + } + if err := json.Unmarshal(env.Result, &sub); err != nil || !subRe.MatchString(sub.Subdomain) { + return "", errors.New("cloudflare: the account has no plain workers.dev subdomain to serve the site from") + } + return "https://" + s.Name + "." + sub.Subdomain + ".workers.dev", nil +} diff --git a/internal/adapter/hosting/cloudflare/cloudflare_test.go b/internal/adapter/hosting/cloudflare/cloudflare_test.go new file mode 100644 index 000000000..b7b9a518a --- /dev/null +++ b/internal/adapter/hosting/cloudflare/cloudflare_test.go @@ -0,0 +1,270 @@ +package cloudflare + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare/cloudflaretest" +) + +func connect(t *testing.T, base string, token string) hosting.Provider { + t.Helper() + return Adapter{BaseURL: base}.Connect(token) +} + +var site = hosting.Site{Name: "example-site", Domain: "docs.example.com"} + +// TestCreateRouteAndReportAgainstTheFake walks the whole host half once: the +// read says nothing exists, the two writes create and route, the read after says +// both hold, and the address is the domain. +func TestCreateRouteAndReportAgainstTheFake(t *testing.T) { + fake := cloudflaretest.New(t) + p := connect(t, fake.URL, cloudflaretest.Token) + ctx := context.Background() + + st, err := p.Inspect(ctx, site) + if err != nil { + t.Fatalf("inspect: %v", err) + } + if st.Exists || st.Routed { + t.Fatalf("a fresh account reports %+v, want nothing", st) + } + if fake.Writes() != 0 { + t.Fatalf("Inspect wrote: %v", fake.CallLog()) + } + if err := p.Create(ctx, site); err != nil { + t.Fatalf("create: %v", err) + } + if err := p.Route(ctx, site); err != nil { + t.Fatalf("route: %v", err) + } + st, err = p.Inspect(ctx, site) + if err != nil { + t.Fatalf("inspect after: %v", err) + } + if !st.Exists || !st.Routed { + t.Fatalf("after create and route, state is %+v", st) + } + addr, err := p.Address(ctx, site) + if err != nil { + t.Fatalf("address: %v", err) + } + if addr != "https://docs.example.com" { + t.Fatalf("address = %q", addr) + } + body := fake.Bodies[cloudflaretest.AttachDomain][0] + if body["hostname"] != "docs.example.com" || body["service"] != "example-site" { + t.Fatalf("route body = %v", body) + } + created := fake.Bodies[cloudflaretest.CreateWorker][0] + if created["name"] != "example-site" { + t.Fatalf("create body = %v", created) + } +} + +// TestAddressWithoutADomainIsTheWorkersDevName: a site with no custom domain +// is served from the account's workers.dev subdomain, and routing it is a no-op. +func TestAddressWithoutADomainIsTheWorkersDevName(t *testing.T) { + fake := cloudflaretest.New(t) + p := connect(t, fake.URL, cloudflaretest.Token) + s := hosting.Site{Name: "example-site"} + ctx := context.Background() + st, err := p.Inspect(ctx, s) + if err != nil || !st.Routed { + t.Fatalf("no-domain site: state %+v err %v; want Routed vacuously true", st, err) + } + if err := p.Route(ctx, s); err != nil { + t.Fatalf("route: %v", err) + } + if fake.Writes() != 0 { + t.Fatalf("routing a site with no domain wrote: %v", fake.CallLog()) + } + addr, err := p.Address(ctx, s) + if err != nil { + t.Fatalf("address: %v", err) + } + if want := "https://example-site." + cloudflaretest.Subdomain + ".workers.dev"; addr != want { + t.Fatalf("address = %q, want %q", addr, want) + } +} + +// TestEveryCallFailsLoudly makes each route the adapter calls fail in turn, and +// requires the step that made the call to return an error naming the route's +// status, never a success and never the credential. +func TestEveryCallFailsLoudly(t *testing.T) { + steps := []struct { + route string + run func(p hosting.Provider) error + }{ + {cloudflaretest.ListAccounts, func(p hosting.Provider) error { _, err := p.Inspect(context.Background(), site); return err }}, + {cloudflaretest.ListWorkers, func(p hosting.Provider) error { _, err := p.Inspect(context.Background(), site); return err }}, + {cloudflaretest.ListDomains, func(p hosting.Provider) error { _, err := p.Inspect(context.Background(), site); return err }}, + {cloudflaretest.CreateWorker, func(p hosting.Provider) error { return p.Create(context.Background(), site) }}, + {cloudflaretest.AttachDomain, func(p hosting.Provider) error { + _ = p.Create(context.Background(), site) + return p.Route(context.Background(), site) + }}, + {cloudflaretest.GetSubdomain, func(p hosting.Provider) error { + _, err := p.Address(context.Background(), hosting.Site{Name: "example-site"}) + return err + }}, + } + for _, st := range steps { + t.Run(st.route, func(t *testing.T) { + fake := cloudflaretest.New(t) + fake.Fail[st.route] = http.StatusInternalServerError + err := st.run(connect(t, fake.URL, cloudflaretest.Token)) + if err == nil { + t.Fatalf("%s failed on the host and the adapter reported success", st.route) + } + if !strings.Contains(err.Error(), "500") { + t.Fatalf("the error does not carry the host's status: %v", err) + } + if strings.Contains(err.Error(), cloudflaretest.Token) { + t.Fatalf("the error carries the credential: %v", err) + } + }) + } +} + +// TestTheCredentialNeverReachesAnError: a host that echoes the Authorization +// header back in its error message must not get the token into the error the +// adapter returns, because that error is printed. +func TestTheCredentialNeverReachesAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"success":false,"errors":[{"code":1,"message":"bad header: ` + r.Header.Get("Authorization") + `"}],"result":null}`)) + })) + defer srv.Close() + _, err := connect(t, srv.URL, cloudflaretest.Token).Inspect(context.Background(), site) + if err == nil { + t.Fatal("a 403 read as success") + } + if strings.Contains(err.Error(), cloudflaretest.Token) { + t.Fatalf("the credential reached the error: %v", err) + } +} + +// TestAWrongCredentialIsRefused: the fake refuses any other bearer, and the +// adapter reports the refusal. +func TestAWrongCredentialIsRefused(t *testing.T) { + fake := cloudflaretest.New(t) + if _, err := connect(t, fake.URL, "not-the-token").Inspect(context.Background(), site); err == nil || + !strings.Contains(err.Error(), "403") { + t.Fatalf("a wrong credential: err = %v, want a 403", err) + } +} + +// TestAccountMustBeUnambiguous: the adapter takes the account from the token's +// reach, so a token that reaches none, or more than one, is refused before any +// write rather than guessed at. +func TestAccountMustBeUnambiguous(t *testing.T) { + for _, tc := range []struct { + name string + accounts []string + want string + }{ + {"none", nil, "no account"}, + {"two", []string{cloudflaretest.Account, "fedcba9876543210fedcba9876543210"}, "2 accounts"}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := cloudflaretest.New(t) + fake.Accounts = tc.accounts + _, err := connect(t, fake.URL, cloudflaretest.Token).Inspect(context.Background(), site) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want one naming %q", err, tc.want) + } + }) + } +} + +// TestADomainOnAnotherHostIsNotMoved: a domain already routed to a different +// Worker is somebody's live site, and the adapter refuses rather than +// re-pointing it. +func TestADomainOnAnotherHostIsNotMoved(t *testing.T) { + fake := cloudflaretest.New(t) + fake.Workers["other-site"] = true + fake.Domains["docs.example.com"] = "other-site" + _, err := connect(t, fake.URL, cloudflaretest.Token).Inspect(context.Background(), site) + if err == nil || !strings.Contains(err.Error(), "other-site") { + t.Fatalf("err = %v, want a refusal naming the Worker that holds the domain", err) + } +} + +// TestTheWorkerListIsPaged: a Worker on the second page of the listing exists. +func TestTheWorkerListIsPaged(t *testing.T) { + fake := cloudflaretest.New(t) + fake.PageSize = 1 + fake.Workers["a-first"] = true + fake.Workers["example-site"] = true + st, err := connect(t, fake.URL, cloudflaretest.Token).Inspect(context.Background(), hosting.Site{Name: "example-site"}) + if err != nil { + t.Fatalf("inspect: %v", err) + } + if !st.Exists { + t.Fatal("a Worker on the second page was reported absent") + } +} + +// TestARedirectIsNotFollowed: a redirect would carry the request somewhere the +// base address did not name. +func TestARedirectIsNotFollowed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://192.0.2.1/elsewhere", http.StatusFound) + })) + defer srv.Close() + if _, err := connect(t, srv.URL, cloudflaretest.Token).Inspect(context.Background(), site); err == nil { + t.Fatal("a redirect was followed or read as success") + } +} + +// TestAnUnsafeNameNeverReachesARequest: the name becomes a path and a body +// field, so the adapter holds it to the host-name charset itself. +func TestAnUnsafeNameNeverReachesARequest(t *testing.T) { + fake := cloudflaretest.New(t) + p := connect(t, fake.URL, cloudflaretest.Token) + for _, s := range []hosting.Site{{Name: "../accounts"}, {Name: "ok-name", Domain: "https://example.com"}} { + if err := p.Create(context.Background(), s); err == nil { + t.Fatalf("Create accepted %+v", s) + } + } + if fake.Writes() != 0 { + t.Fatalf("an unsafe name reached the host: %v", fake.CallLog()) + } +} + +// TestRepositoryHalf pins the data the verb writes into the repository: the +// host configuration names the Worker and its route and carries no secret, and +// the deploy step reads its credential from the named secrets alone. +func TestRepositoryHalf(t *testing.T) { + a := Adapter{} + if a.Name() != "cloudflare" { + t.Fatalf("name = %q", a.Name()) + } + rel, data := a.HostConfig(site) + if rel != "wrangler.jsonc" { + t.Fatalf("host config path = %q", rel) + } + text := string(data) + for _, want := range []string{`"name": "example-site"`, `"pattern": "docs.example.com"`, `"directory": "./site"`} { + if !strings.Contains(text, want) { + t.Fatalf("host config lacks %s:\n%s", want, text) + } + } + if _, bare := a.HostConfig(hosting.Site{Name: "example-site"}); strings.Contains(string(bare), `"routes":`) { + t.Fatalf("a site with no domain declares routes:\n%s", bare) + } + step := a.DeployStep() + for _, s := range a.Secrets() { + if !strings.Contains(step, "${{ secrets."+s+" }}") { + t.Fatalf("the deploy step does not read secret %s:\n%s", s, step) + } + } + if strings.Contains(step, "run:") { + t.Fatalf("the deploy step runs a shell; it should only call the pinned action:\n%s", step) + } +} diff --git a/internal/adapter/hosting/cloudflare/cloudflaretest/fake.go b/internal/adapter/hosting/cloudflare/cloudflaretest/fake.go new file mode 100644 index 000000000..25eb88e1f --- /dev/null +++ b/internal/adapter/hosting/cloudflare/cloudflaretest/fake.go @@ -0,0 +1,260 @@ +// Package cloudflaretest is an in-process fake of the slice of the Cloudflare +// v4 API the cloudflare hosting adapter calls, for tests only. No test in this +// repository reaches the real service: the adapter and the verb above it are +// exercised against this server, and every route it answers can be made to +// fail, so each error path is a test rather than a hope. +// +// The fake answers the documented shapes (the v4 envelope: success, errors, +// result, result_info) for exactly these routes: +// +// GET /accounts +// GET /accounts/{account}/workers/workers +// POST /accounts/{account}/workers/workers +// GET /accounts/{account}/workers/domains +// PUT /accounts/{account}/workers/domains +// GET /accounts/{account}/workers/subdomain +// +// Anything else is a 404, so a request the adapter was not written to make +// fails the test that provoked it. +package cloudflaretest + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sort" + "strconv" + "strings" + "sync" + "testing" +) + +// Token is the credential the fake accepts. Any other bearer is a 403. +const Token = "cf-fake-token-0000000000000000000000000000" + +// Account is the one account the default fake holds. +const Account = "0123456789abcdef0123456789abcdef" + +// Subdomain is the account's workers.dev subdomain. +const Subdomain = "example-sub" + +// Route keys name a route for Fail and for the call log. +const ( + ListAccounts = "GET accounts" + ListWorkers = "GET workers" + CreateWorker = "POST workers" + ListDomains = "GET domains" + AttachDomain = "PUT domains" + GetSubdomain = "GET subdomain" + unknownRoute = "unknown" + envelopeError = 10000 +) + +// Server is the fake. Its fields are the host's state; lock with Mu to read +// them while a request may be in flight. +type Server struct { + *httptest.Server + Mu sync.Mutex + // Accounts the token reaches. + Accounts []string + // Workers by name. + Workers map[string]bool + // Domains maps a hostname to the Worker it is routed to. + Domains map[string]string + // Fail answers a route with this HTTP status instead of serving it. + Fail map[string]int + // PageSize splits the Workers list into pages of this size (0 = one page). + PageSize int + // Calls is every request received, as its route key. + Calls []string + // Bodies holds each write's decoded JSON body, keyed by route. + Bodies map[string][]map[string]any + // Auth records every Authorization header value received, so a test can + // assert the credential went where it should and nowhere else. + Auth []string +} + +// New starts a fake with one account, no Workers and no domains. +func New(t testing.TB) *Server { + t.Helper() + s := &Server{ + Accounts: []string{Account}, + Workers: map[string]bool{}, + Domains: map[string]string{}, + Fail: map[string]int{}, + Bodies: map[string][]map[string]any{}, + } + s.Server = httptest.NewServer(http.HandlerFunc(s.serve)) + t.Cleanup(s.Close) + return s +} + +// Writes is how many write requests the fake received. +func (s *Server) Writes() int { + s.Mu.Lock() + defer s.Mu.Unlock() + n := 0 + for _, c := range s.Calls { + if !strings.HasPrefix(c, "GET ") { + n++ + } + } + return n +} + +// CallLog returns a copy of the call log. +func (s *Server) CallLog() []string { + s.Mu.Lock() + defer s.Mu.Unlock() + return append([]string(nil), s.Calls...) +} + +func (s *Server) route(r *http.Request) (key, account string) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + switch { + case len(parts) == 1 && parts[0] == "accounts" && r.Method == http.MethodGet: + return ListAccounts, "" + case len(parts) == 4 && parts[0] == "accounts" && parts[2] == "workers": + account = parts[1] + switch { + case parts[3] == "workers" && r.Method == http.MethodGet: + return ListWorkers, account + case parts[3] == "workers" && r.Method == http.MethodPost: + return CreateWorker, account + case parts[3] == "domains" && r.Method == http.MethodGet: + return ListDomains, account + case parts[3] == "domains" && r.Method == http.MethodPut: + return AttachDomain, account + case parts[3] == "subdomain" && r.Method == http.MethodGet: + return GetSubdomain, account + } + } + return unknownRoute, "" +} + +func (s *Server) serve(w http.ResponseWriter, r *http.Request) { + s.Mu.Lock() + defer s.Mu.Unlock() + key, account := s.route(r) + s.Calls = append(s.Calls, key) + s.Auth = append(s.Auth, r.Header.Get("Authorization")) + if r.Header.Get("Authorization") != "Bearer "+Token { + fail(w, http.StatusForbidden, "Authentication error") + return + } + if code, ok := s.Fail[key]; ok { + fail(w, code, "injected failure on "+key) + return + } + if key == unknownRoute { + fail(w, http.StatusNotFound, "no such route") + return + } + if account != "" && !contains(s.Accounts, account) { + fail(w, http.StatusForbidden, "account not reachable") + return + } + var body map[string]any + if r.Method != http.MethodGet { + raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err := json.Unmarshal(raw, &body); err != nil { + fail(w, http.StatusBadRequest, "body is not JSON") + return + } + s.Bodies[key] = append(s.Bodies[key], body) + } + switch key { + case ListAccounts: + var out []map[string]string + for _, a := range s.Accounts { + out = append(out, map[string]string{"id": a, "name": "account " + a[:4]}) + } + ok(w, out, nil) + case ListWorkers: + names := make([]string, 0, len(s.Workers)) + for n := range s.Workers { + names = append(names, n) + } + sort.Strings(names) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + size := s.PageSize + if size <= 0 { + size = len(names) + 1 + } + total := (len(names) + size - 1) / size + if total == 0 { + total = 1 + } + lo, hi := (page-1)*size, page*size + if lo > len(names) { + lo = len(names) + } + if hi > len(names) { + hi = len(names) + } + var out []map[string]any + for _, n := range names[lo:hi] { + out = append(out, map[string]any{"id": "id-" + n, "name": n}) + } + ok(w, out, map[string]int{"page": page, "total_pages": total}) + case CreateWorker: + name, _ := body["name"].(string) + if name == "" || s.Workers[name] { + fail(w, http.StatusConflict, "worker exists or has no name") + return + } + s.Workers[name] = true + ok(w, map[string]any{"id": "id-" + name, "name": name}, nil) + case ListDomains: + host := r.URL.Query().Get("hostname") + var out []map[string]string + for h, svc := range s.Domains { + if host == "" || h == host { + out = append(out, map[string]string{"hostname": h, "service": svc}) + } + } + ok(w, out, nil) + case AttachDomain: + host, _ := body["hostname"].(string) + svc, _ := body["service"].(string) + if !s.Workers[svc] { + fail(w, http.StatusBadRequest, "no such worker") + return + } + s.Domains[host] = svc + ok(w, map[string]string{"hostname": host, "service": svc}, nil) + case GetSubdomain: + ok(w, map[string]string{"subdomain": Subdomain}, nil) + } +} + +func ok(w http.ResponseWriter, result any, info any) { + env := map[string]any{"success": true, "errors": []any{}, "messages": []any{}, "result": result} + if info != nil { + env["result_info"] = info + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(env) +} + +func fail(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, "result": nil, + "errors": []map[string]any{{"code": envelopeError, "message": msg}}, + }) +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} diff --git a/internal/adapter/hosting/hosting.go b/internal/adapter/hosting/hosting.go new file mode 100644 index 000000000..2cb4ddb61 --- /dev/null +++ b/internal/adapter/hosting/hosting.go @@ -0,0 +1,75 @@ +// Package hosting is the provider seam `abcd site setup` routes a rendered site +// through (itd-2609061543533170, spc-2609212141407459 scope 2). It is the second +// seam under internal/adapter beside the scanners, and it follows their shape: +// the core consumes the interface, never a vendor, and a provider is one +// implementation of it. +// +// One provider ships (cloudflare, the host abcd's own site uses). A second is a +// new implementation of Adapter and one line in the core's provider list, never +// a change to the verb. +// +// The seam has two halves because a provider touches the site twice: +// +// - in the REPOSITORY, it names the secrets its deploy job reads, the deploy +// step itself, and the host configuration file that step deploys from. +// These are data, rendered into files the verb writes; nothing here runs. +// - on the HOST, through its API and the person's credential, it creates the +// host, routes the domain to it and reports the live address. Every call +// is made only after the verb's confirmation, and Inspect, the read, is the +// only call made before it. +// +// The credential is handed to Connect and lives only inside the returned +// Provider. No method returns it, no error carries it, and nothing writes it. +package hosting + +import "context" + +// Site is what a provider hosts: the host's name, and the custom domain routed +// to it, if any. Both are validated by the caller before they reach a provider +// and again by the provider before they reach a request. +type Site struct { + Name string + Domain string +} + +// State is what the host holds for a site, read without writing anything. +type State struct { + // Exists is true when the host already exists. + Exists bool + // Routed is true when the domain is already routed to the host, and + // vacuously true when the site asks for no domain. + Routed bool +} + +// Provider is the live half of the seam: one connected account. +type Provider interface { + // Inspect reads what the host holds for s. It writes nothing. + Inspect(ctx context.Context, s Site) (State, error) + // Create creates the host for s. + Create(ctx context.Context, s Site) error + // Route routes s.Domain to the host. A site with no domain is a no-op. + Route(ctx context.Context, s Site) error + // Address reports the address the site is served from. + Address(ctx context.Context, s Site) (string, error) +} + +// Adapter is one provider: the repository half as data, and Connect for the +// host half. It is the interface a second provider implements. +type Adapter interface { + // Name is the provider's key in the composition manifest's hosting block. + Name() string + // CredentialName is the name the credential is resolved by on this + // machine. It names the credential; it never is one. + CredentialName() string + // Secrets are the forge environment secrets the deploy job reads, by name. + Secrets() []string + // DeployStep is the workflow step, as YAML indented for a job's step list, + // that deploys the unpacked site from ./site. It reads its credentials from + // the secrets named by Secrets and nothing else. + DeployStep() string + // HostConfig is the committed configuration file the deploy step reads, + // repo-relative, and its bytes for s. + HostConfig(s Site) (rel string, data []byte) + // Connect returns a Provider acting with token. The token stays inside it. + Connect(token string) Provider +} diff --git a/internal/core/ahoy/remote.go b/internal/core/ahoy/remote.go index 32466ad86..8325880b6 100644 --- a/internal/core/ahoy/remote.go +++ b/internal/core/ahoy/remote.go @@ -563,3 +563,19 @@ func writeRepoSettingsMirror(cwd, repo string, merge RemoteMergeHygiene) (bool, } return true, nil } + +// GitHubRepo is the owner/name this checkout's origin remote names, under the +// same github.com-only, plain-name rules the remote apply writes behind. Every +// verb that writes to the forge resolves its repository here, so there is one +// answer to "which repository may this write reach". +func GitHubRepo(cwd string) (string, error) { return resolveGitHubRepo(cwd) } + +// GH runs one `gh` subcommand under cwd with the same bounds the remote apply +// uses: a timeout, a capped read, and the caller's own authenticated identity, +// so a forge write is made by the person who invoked the verb and abcd never +// holds a forge token. Callers pass `--hostname github.com` explicitly for the +// reason githubHost states. +func GH(cwd string, stdin []byte, args ...string) ([]byte, error) { return runGH(cwd, stdin, args...) } + +// GitHubHost is the API host every forge request names explicitly. +const GitHubHost = githubHost diff --git a/internal/core/credential/credential.go b/internal/core/credential/credential.go new file mode 100644 index 000000000..25c87113f --- /dev/null +++ b/internal/core/credential/credential.go @@ -0,0 +1,112 @@ +// Package credential is the one reader every adapter resolves an external +// credential through, by NAME. +// +// This is the interim source itd-2609061543533170 ruled for `abcd site setup` +// (its `## Decisions`, 2026-09-25): the credential store proper, with its three +// homes and its walkthrough, is itd-2609221017023290, which is planned and not +// built. Until it lands, a credential is read from one machine-scoped file, +// ~/.abcd/credentials.json, a JSON object mapping a credential name to its +// value. The successor replaces the source behind Source; no reader changes. +// +// The file is refused, loudly and never treated as absent, unless it is a +// regular file (not a symlink), owned by the caller, and readable and writable +// by the owner alone (mode 0600 or tighter): a secret that group or other can +// read is not kept, and one that somebody else wrote is not the caller's. +// +// The value never leaves Resolve except as its return: no error formats it, +// nothing logs it, and nothing here writes anywhere, least of all the +// repository. A malformed file is refused without echoing a byte of it. +package credential + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// StoreFileName is the interim store's file under ~/.abcd/. +const StoreFileName = "credentials.json" + +// maxStoreBytes bounds the store read. +const maxStoreBytes = 64 << 10 + +// ErrNotSet is a credential that resolves to nothing: no store, no entry, or an +// empty value. It is the one error a caller may treat as "carry on without it". +var ErrNotSet = errors.New("credential not set") + +// Source resolves a credential by name. +type Source interface { + Resolve(name string) (string, error) +} + +// nameRe is the credential-name charset. A name is looked up, never used as a +// path, but it is printed in refusals, so it is held to plain characters. +var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + +// Machine is the interim machine-scoped source rooted at home (the caller's +// home directory). An empty home resolves every name to ErrNotSet. +func Machine(home string) Source { return machine{home: home} } + +// UserMachine is Machine at the caller's home directory. +func UserMachine() Source { + home, err := os.UserHomeDir() + if err != nil { + home = "" + } + return Machine(home) +} + +type machine struct{ home string } + +// StorePath is where the interim store lives, displayed with ~ so no +// developer-identity path reaches output. +const StorePath = "~/.abcd/" + StoreFileName + +func (m machine) Resolve(name string) (string, error) { + if !nameRe.MatchString(name) { + return "", errors.New("credential: the name is not a plain credential name") + } + if m.home == "" { + return "", ErrNotSet + } + p := filepath.Join(m.home, ".abcd", StoreFileName) + fi, err := os.Lstat(p) + if err != nil { + if os.IsNotExist(err) { + return "", ErrNotSet + } + return "", fmt.Errorf("credential: %s could not be examined, so it is not read", StorePath) + } + if !fi.Mode().IsRegular() { + return "", fmt.Errorf("credential: %s is not a regular file (a symlink is never followed), so it is not read", StorePath) + } + if fi.Mode().Perm()&0o077 != 0 { + return "", fmt.Errorf("credential: %s can be read or written by group or other (mode %04o), so it is not read; `chmod 0600 %s`", StorePath, fi.Mode().Perm(), StorePath) + } + // ReadDeclaration re-checks the leaf on its own descriptor and refuses a + // file this uid does not own. + raw, refusal, err := fsutil.ReadDeclaration(p, maxStoreBytes) + switch { + case refusal == fsutil.DeclarationAbsent && errors.Is(err, os.ErrNotExist): + return "", ErrNotSet + case refusal == fsutil.DeclarationForeignOwner: + return "", fmt.Errorf("credential: %s is not owned by you, so it is not read", StorePath) + case err != nil: + return "", fmt.Errorf("credential: %s could not be read safely (mode 0600, owned by you, a regular file), so it is not read", StorePath) + } + var store map[string]string + if err := json.Unmarshal(raw, &store); err != nil { + // The decoder's message can quote the file's bytes; it is dropped. + return "", fmt.Errorf("credential: %s is not a JSON object of names to strings", StorePath) + } + v := store[name] + if v == "" { + return "", ErrNotSet + } + return v, nil +} diff --git a/internal/core/credential/credential_test.go b/internal/core/credential/credential_test.go new file mode 100644 index 000000000..da1709e35 --- /dev/null +++ b/internal/core/credential/credential_test.go @@ -0,0 +1,114 @@ +package credential + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +const secretValue = "tok-0123456789-not-a-real-secret" + +func writeStore(t *testing.T, home, body string, mode os.FileMode) string { + t.Helper() + dir := filepath.Join(home, ".abcd") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, StoreFileName) + if err := os.WriteFile(p, []byte(body), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatal(err) + } + return p +} + +func TestResolveReadsAnOwnerOnlyStore(t *testing.T) { + home := t.TempDir() + writeStore(t, home, `{"hosting.cloudflare": "`+secretValue+`"}`, 0o600) + got, err := Machine(home).Resolve("hosting.cloudflare") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != secretValue { + t.Fatal("resolve returned a different value from the one stored") + } +} + +func TestAnAbsentStoreOrNameIsNotSet(t *testing.T) { + home := t.TempDir() + if _, err := Machine(home).Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) { + t.Fatalf("no store: err = %v, want ErrNotSet", err) + } + writeStore(t, home, `{"other": "x"}`, 0o600) + if _, err := Machine(home).Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) { + t.Fatalf("absent name: err = %v, want ErrNotSet", err) + } + writeStore(t, home, `{"hosting.cloudflare": ""}`, 0o600) + if _, err := Machine(home).Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) { + t.Fatalf("empty value: err = %v, want ErrNotSet", err) + } +} + +// TestAStoreOthersCanReadIsRefused: a secret group or other can read is not +// kept, and it is refused loudly, never read and never treated as absent. +func TestAStoreOthersCanReadIsRefused(t *testing.T) { + for _, mode := range []os.FileMode{0o640, 0o604, 0o644, 0o660} { + home := t.TempDir() + writeStore(t, home, `{"hosting.cloudflare": "`+secretValue+`"}`, mode) + v, err := Machine(home).Resolve("hosting.cloudflare") + if err == nil || errors.Is(err, ErrNotSet) { + t.Fatalf("mode %o: err = %v, want a refusal", mode, err) + } + if v != "" || strings.Contains(err.Error(), secretValue) { + t.Fatalf("mode %o: the refusal carries the value", mode) + } + if !strings.Contains(err.Error(), "0600") { + t.Fatalf("mode %o: the refusal does not name the remedy: %v", mode, err) + } + } +} + +func TestASymlinkedStoreIsRefused(t *testing.T) { + home := t.TempDir() + real := filepath.Join(t.TempDir(), "elsewhere.json") + if err := os.WriteFile(real, []byte(`{"hosting.cloudflare": "`+secretValue+`"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, filepath.Join(home, ".abcd", StoreFileName)); err != nil { + t.Fatal(err) + } + if _, err := Machine(home).Resolve("hosting.cloudflare"); err == nil || errors.Is(err, ErrNotSet) { + t.Fatalf("a symlinked store: err = %v, want a refusal", err) + } +} + +func TestAMalformedStoreNeverEchoesItsBytes(t *testing.T) { + home := t.TempDir() + writeStore(t, home, `{"hosting.cloudflare": "`+secretValue+`",,}`, 0o600) + _, err := Machine(home).Resolve("hosting.cloudflare") + if err == nil || errors.Is(err, ErrNotSet) { + t.Fatalf("malformed: err = %v, want a refusal", err) + } + if strings.Contains(err.Error(), secretValue) { + t.Fatal("the refusal echoes the store's contents") + } +} + +func TestAnUnsafeNameIsRefused(t *testing.T) { + if _, err := Machine(t.TempDir()).Resolve("../x"); err == nil || errors.Is(err, ErrNotSet) { + t.Fatalf("err = %v, want a refusal", err) + } +} + +func TestNoHomeIsNotSet(t *testing.T) { + if _, err := Machine("").Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) { + t.Fatalf("err = %v, want ErrNotSet", err) + } +} diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go index efd8a6286..b0d2f6660 100644 --- a/internal/core/launch/scaffold/scaffold.go +++ b/internal/core/launch/scaffold/scaffold.go @@ -38,6 +38,9 @@ const ( // sibling was hand-edited) or a write faulted first, so it was NOT written. The // scaffold is all-or-nothing, so this reports honestly that nothing landed. StatusSkipped FileStatus = "skipped" + // StatusKept — a seed file (PlannedFile.Seed) that already exists: it is the + // repository's own, so it was left exactly as it is. + StatusKept FileStatus = "kept" ) // disposition is the pre-write classification of a target: what it is on disk, @@ -113,43 +116,72 @@ func Scaffold(req Request) (Report, error) { } report := Report{Substitutions: subs, DefaultBranch: branch, GoVersion: goVersion, CIChecks: subs.CIChecks} - planned := []struct { - rel string - data []byte - }{ - {ReleaseYMLPath, rendered.ReleaseYML}, - {AutoReleaseYMLPath, rendered.AutoReleaseYML}, - {RunbookPath, rendered.Runbook}, - {CheckReviewsPath, rendered.CheckReviews}, + outcomes, wrote, refused, err := WriteFiles(req.RepoRoot, []PlannedFile{ + {Path: ReleaseYMLPath, Data: rendered.ReleaseYML}, + {Path: AutoReleaseYMLPath, Data: rendered.AutoReleaseYML}, + {Path: RunbookPath, Data: rendered.Runbook}, + {Path: CheckReviewsPath, Data: rendered.CheckReviews}, + }, req.Confirm) + report.Files, report.Wrote, report.Refused = outcomes, wrote, refused + if err != nil { + return report, err } + report.NoOp = wrote == 0 + return report, nil +} +// PlannedFile is one file a WriteFiles run places, at a slash-separated +// repo-relative Path. +type PlannedFile struct { + Path string + Data []byte + // Seed marks a file the repository owns once it exists: written when + // absent, and otherwise left exactly as it is (StatusKept), never refused + // and never overwritten, --confirm or not. Machinery is the opposite: abcd + // owns its bytes, so a copy that differs is refused or, confirmed, replaced. + Seed bool +} + +// WriteFiles is the scaffold's writer, shared with every verb that lays +// machinery into a managed repository (`abcd site setup` is the second). It is +// idempotent and fail-safe: +// +// - a file absent on disk is written; +// - a file byte-identical to the planned bytes is a no-op (StatusCurrent); +// - a machinery file that exists and DIFFERS is REFUSED and left untouched +// unless confirm is set, in which case it is overwritten; +// - a seed file that exists is kept (StatusKept) whatever it holds. +// +// A run that refuses any file writes NOTHING and returns ErrScaffoldBlocked with +// the outcomes, so the caller can render exactly what was and was not touched — +// no partial half-write. +func WriteFiles(repoRoot string, planned []PlannedFile, confirm bool) (outcomes []FileOutcome, wrote, refused int, err error) { // First pass: classify every file WITHOUT writing. A refusal on any file with - // Confirm unset aborts the whole run before a single write, so the scaffold is + // confirm unset aborts the whole run before a single write, so the scaffold is // all-or-nothing rather than half-applied. Nothing is marked "written" here — // a planned write is only tentative until the second pass commits it, so the // report never claims a file landed that did not (the StatusWritten contract). - outcomes := make([]FileOutcome, len(planned)) + outcomes = make([]FileOutcome, len(planned)) writeNeeded := make([]bool, len(planned)) overwrite := make([]bool, len(planned)) - refused := 0 for i, p := range planned { - abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel)) - disp, detail := classify(abs, p.data) - switch disp { - case dispCurrent: - outcomes[i] = FileOutcome{Path: p.rel, Status: StatusCurrent} - case dispAbsent: + abs := filepath.Join(repoRoot, filepath.FromSlash(p.Path)) + disp, detail := classify(abs, p.Data) + switch { + case disp == dispCurrent: + outcomes[i] = FileOutcome{Path: p.Path, Status: StatusCurrent} + case disp == dispAbsent: writeNeeded[i] = true - outcomes[i] = FileOutcome{Path: p.rel, Status: StatusSkipped} // provisional until written - case dispDiffers: - if req.Confirm { - writeNeeded[i] = true - overwrite[i] = true - outcomes[i] = FileOutcome{Path: p.rel, Status: StatusSkipped} // provisional until written - } else { - refused++ - outcomes[i] = FileOutcome{Path: p.rel, Status: StatusRefused, Detail: detail} - } + outcomes[i] = FileOutcome{Path: p.Path, Status: StatusSkipped} // provisional until written + case p.Seed: + outcomes[i] = FileOutcome{Path: p.Path, Status: StatusKept, Detail: "present: the repository's own, left as it is"} + case confirm: + writeNeeded[i] = true + overwrite[i] = true + outcomes[i] = FileOutcome{Path: p.Path, Status: StatusSkipped} // provisional until written + default: + refused++ + outcomes[i] = FileOutcome{Path: p.Path, Status: StatusRefused, Detail: detail} } } @@ -162,28 +194,23 @@ func Scaffold(req Request) (Report, error) { outcomes[i].Detail = "not written: the run refused because another file was hand-edited (all-or-nothing)" } } - report.Files = outcomes - report.Refused = refused - return report, ErrScaffoldBlocked + return outcomes, 0, refused, ErrScaffoldBlocked } // Second pass: commit the writes. Every file that reaches here is either a // create or a confirmed overwrite; a file becomes StatusWritten only once its // write actually succeeds. - wrote := 0 for i, p := range planned { if !writeNeeded[i] { continue } - abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel)) - if err := fsutil.WriteFileAtomicPreserveMode(abs, p.data); err != nil { + abs := filepath.Join(repoRoot, filepath.FromSlash(p.Path)) + if err := fsutil.WriteFileAtomicPreserveMode(abs, p.Data); err != nil { // The atomic writer never leaves a half-written file; the files written // before this fault are marked written, this one and any later planned // file stay StatusSkipped (not written), so the report matches disk. outcomes[i].Detail = "not written: a write faulted on this file" - report.Files = outcomes - report.Wrote = wrote - return report, fmt.Errorf("scaffold: write %s: %w", p.rel, err) + return outcomes, wrote, 0, fmt.Errorf("scaffold: write %s: %w", p.Path, err) } outcomes[i].Status = StatusWritten if overwrite[i] { @@ -191,11 +218,7 @@ func Scaffold(req Request) (Report, error) { } wrote++ } - - report.Files = outcomes - report.Wrote = wrote - report.NoOp = wrote == 0 - return report, nil + return outcomes, wrote, 0, nil } // classify reports whether abs is absent (→ dispAbsent, write it), byte-equal to diff --git a/internal/core/launch/scaffold/writefiles_test.go b/internal/core/launch/scaffold/writefiles_test.go new file mode 100644 index 000000000..59a1ee1d4 --- /dev/null +++ b/internal/core/launch/scaffold/writefiles_test.go @@ -0,0 +1,55 @@ +package scaffold + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// TestWriteFilesKeepsASeedAndRefusesDriftedMachinery: a seed file the +// repository already has is its own and is kept whatever it holds, while a +// machinery file that drifted refuses the whole run, and the refusal writes +// nothing — not even the absent seed beside it. +func TestWriteFilesKeepsASeedAndRefusesDriftedMachinery(t *testing.T) { + root := t.TempDir() + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + must(os.MkdirAll(filepath.Join(root, "a"), 0o755)) + must(os.WriteFile(filepath.Join(root, "a", "seed.json"), []byte("the repository's own\n"), 0o644)) + + out, wrote, refused, err := WriteFiles(root, []PlannedFile{ + {Path: "a/seed.json", Data: []byte("abcd's default\n"), Seed: true}, + {Path: "a/new-seed.json", Data: []byte("new\n"), Seed: true}, + {Path: "b/machine.yml", Data: []byte("machinery\n")}, + }, false) + if err != nil || wrote != 2 || refused != 0 { + t.Fatalf("first run: wrote %d refused %d err %v", wrote, refused, err) + } + if out[0].Status != StatusKept { + t.Fatalf("a present seed is %q, want kept", out[0].Status) + } + if got, _ := os.ReadFile(filepath.Join(root, "a", "seed.json")); string(got) != "the repository's own\n" { + t.Fatalf("the present seed was rewritten: %q", got) + } + + must(os.WriteFile(filepath.Join(root, "b", "machine.yml"), []byte("hand-edited\n"), 0o644)) + must(os.Remove(filepath.Join(root, "a", "new-seed.json"))) + out, wrote, refused, err = WriteFiles(root, []PlannedFile{ + {Path: "a/new-seed.json", Data: []byte("new\n"), Seed: true}, + {Path: "b/machine.yml", Data: []byte("machinery\n")}, + }, false) + if !errors.Is(err, ErrScaffoldBlocked) || wrote != 0 || refused != 1 { + t.Fatalf("drifted machinery: wrote %d refused %d err %v", wrote, refused, err) + } + if _, serr := os.Stat(filepath.Join(root, "a", "new-seed.json")); !os.IsNotExist(serr) { + t.Fatal("a refused run wrote the absent seed") + } + if out[1].Status != StatusRefused { + t.Fatalf("drifted machinery is %q, want refused", out[1].Status) + } +} diff --git a/internal/core/site/compose.go b/internal/core/site/compose.go index 4cd4aea99..a4a09ccb8 100644 --- a/internal/core/site/compose.go +++ b/internal/core/site/compose.go @@ -332,7 +332,9 @@ func (c *composer) headerFor(active string) string { b.WriteString(`` + escapeText(c.ui.NavStory) + ``) b.WriteString(`` + escapeText(c.ui.NavInstall) + ``) b.WriteString(`` + escapeText(c.ui.NavDocs) + ``) - b.WriteString(`` + escapeText(c.ui.NavRecord) + ``) + if c.manifest.Pages.resolve().explorer { + b.WriteString(`` + escapeText(c.ui.NavRecord) + ``) + } if c.repo.Repository != "" { b.WriteString(`` + escapeText(c.forgeLabel()) + ` ↗`) } diff --git a/internal/core/site/explorer.go b/internal/core/site/explorer.go index 72a41c724..94ad60aea 100644 --- a/internal/core/site/explorer.go +++ b/internal/core/site/explorer.go @@ -36,7 +36,6 @@ import ( const ( routeDashboard = "record/" routeGraph = "record/graph/" - routeTimeline = "record/timeline/" routeFoundations = "record/foundations/" routeDevelopment = "record/development/" routeHealth = "record/health/" @@ -92,6 +91,8 @@ type explorer struct { eyebrow, eyebrowSrc string // bib is the bibliography, or nil where the repository keeps none. bib *Bibliography + // pages is the page set the manifest's switches leave on. + pages pageSet } // newExplorer indexes the export for the pages. @@ -110,10 +111,17 @@ func newExplorer(c *composer, export RecordExport, bib *Bibliography, recordRoot mentions: map[string][]string{}, stubs: map[string][]ExportEdge{}, glossaryByPath: map[string]glossaryEntry{}, - } - entries, err := loadGlossaryEntries(c.root) - if err != nil { - return nil, err + pages: c.manifest.Pages.resolve(), + } + // A glossary switched off is a glossary the explorer never loads: no pages, + // no navigation entry and no term links, the same graceful absence a + // repository that keeps none gets. + var entries []glossaryEntry + if e.pages.glossary { + var err error + if entries, err = loadGlossaryEntries(c.root); err != nil { + return nil, err + } } e.glossary = entries for _, en := range entries { @@ -171,6 +179,9 @@ func (e *explorer) hasReferences() bool { return e.bib != nil && len(e.bib.Entri // Pages renders every explorer page, keyed by its output path. func (e *explorer) Pages() (map[string]string, error) { pages := map[string]string{} + if !e.pages.explorer { + return pages, nil + } add := func(route string, render func() (string, error)) error { html, err := render() if err != nil { @@ -182,8 +193,10 @@ func (e *explorer) Pages() (map[string]string, error) { if err := add(routeDashboard, e.dashboard); err != nil { return nil, err } - if err := add(routeGraph, e.graphPage); err != nil { - return nil, err + if e.pages.graph { + if err := add(routeGraph, e.graphPage); err != nil { + return nil, err + } } if err := add(routeContributors, e.contributorsPage); err != nil { return nil, err @@ -281,7 +294,9 @@ func (e *explorer) subnav(active string) string { if e.hasDevelopment() { tabs = append(tabs, tab{routeDevelopment, e.c.ui.RecordNav.Development}) } - tabs = append(tabs, tab{routeGraph, e.c.ui.RecordNav.Graph}) + if e.pages.graph { + tabs = append(tabs, tab{routeGraph, e.c.ui.RecordNav.Graph}) + } if e.hasHealth() { tabs = append(tabs, tab{routeHealth, e.c.ui.RecordNav.Health}) } @@ -471,8 +486,10 @@ func (e *explorer) dashboard() (string, error) { // The genealogy sits directly under the counts, folded shut: it is how the // record got where it is, which a reader asks for rather than arrives at. - b.WriteString(panelDisclosure("c12", ui.RecordNav.Timeline, "", - strconv.Itoa(len(e.export.Releases))+" "+ui.Tiles.Releases, e.genealogy())) + if e.pages.timeline { + b.WriteString(panelDisclosure("c12", ui.RecordNav.Timeline, "", + strconv.Itoa(len(e.export.Releases))+" "+ui.Tiles.Releases, e.genealogy())) + } // State bars, one per store that grades its records at all. for _, typ := range e.storeOrder() { diff --git a/internal/core/site/forge.go b/internal/core/site/forge.go new file mode 100644 index 000000000..a280367a5 --- /dev/null +++ b/internal/core/site/forge.go @@ -0,0 +1,162 @@ +package site + +import ( + "context" + "encoding/json" + "errors" + "net/url" + + "github.com/intentdriven/abcd/internal/core/ahoy" +) + +// Forge is the slice of the forge `abcd site setup` reads and writes: the +// deployment environments the site workflow runs in, and the NAMES of the +// secrets one of them holds. It never reads or writes a secret's value — the +// forge encrypts those, and setting one is the person's step. +type Forge interface { + // Repo names the repository every call acts on. + Repo() string + // Environments reads every environment the repository has, by name. + Environments(ctx context.Context) (map[string]EnvironmentState, error) + // Policies reads one environment's deployment branch and tag policies. + Policies(ctx context.Context, env string) ([]BranchPolicy, error) + // PutEnvironment creates env restricted to custom policies. The forge's + // write replaces an environment's whole protection set, so setup calls it + // only for an environment that does not exist. + PutEnvironment(ctx context.Context, env string) error + // AddPolicy admits one branch or tag pattern to env. + AddPolicy(ctx context.Context, env string, p BranchPolicy) error + // SecretNames lists the names of env's secrets. Values are never read. + SecretNames(ctx context.Context, env string) ([]string, error) +} + +// EnvironmentState is one environment's deployment policy as the forge reports +// it. An environment with no policy at all admits every ref. +type EnvironmentState struct { + ProtectedBranches bool + CustomBranchPolicies bool +} + +// BranchPolicy is one deployment policy: a branch or tag name pattern. +type BranchPolicy struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// ghForge is Forge over the GitHub CLI, acting as the person who invoked the +// verb (ahoy.GH): abcd holds no forge token. +type ghForge struct { + dir string + repo string +} + +// GitHubForge is the forge for the checkout at dir, or an error saying why the +// checkout names no GitHub repository abcd may act on. +func GitHubForge(dir string) (Forge, error) { + repo, err := ahoy.GitHubRepo(dir) + if err != nil { + return nil, err + } + return &ghForge{dir: dir, repo: repo}, nil +} + +func (g *ghForge) Repo() string { return g.repo } + +func (g *ghForge) api(stdin []byte, method, path string) ([]byte, error) { + args := []string{"api", "--hostname", ahoy.GitHubHost, "-H", "Accept: application/vnd.github+json"} + if method != "" { + args = append(args, "--method", method) + } + args = append(args, path) + if stdin != nil { + args = append(args, "--input", "-") + } + return ahoy.GH(g.dir, stdin, args...) +} + +// envPath builds an environment path. The environment names are this +// package's own constants, escaped regardless. +func (g *ghForge) envPath(env, rest string) string { + return "repos/" + g.repo + "/environments/" + url.PathEscape(env) + rest +} + +func (g *ghForge) Environments(context.Context) (map[string]EnvironmentState, error) { + out, err := g.api(nil, "", "repos/"+g.repo+"/environments?per_page=100") + if err != nil { + return nil, err + } + var doc struct { + Environments []struct { + Name string `json:"name"` + Policy *struct { + Protected bool `json:"protected_branches"` + Custom bool `json:"custom_branch_policies"` + } `json:"deployment_branch_policy"` + } `json:"environments"` + } + if err := json.Unmarshal(out, &doc); err != nil { + return nil, errors.New("the environments response could not be read as JSON") + } + envs := map[string]EnvironmentState{} + for _, e := range doc.Environments { + st := EnvironmentState{} + if e.Policy != nil { + st.ProtectedBranches, st.CustomBranchPolicies = e.Policy.Protected, e.Policy.Custom + } + envs[e.Name] = st + } + return envs, nil +} + +func (g *ghForge) Policies(_ context.Context, env string) ([]BranchPolicy, error) { + out, err := g.api(nil, "", g.envPath(env, "/deployment-branch-policies?per_page=100")) + if err != nil { + return nil, err + } + var doc struct { + Policies []BranchPolicy `json:"branch_policies"` + } + if err := json.Unmarshal(out, &doc); err != nil { + return nil, errors.New("the deployment policies response could not be read as JSON") + } + for i := range doc.Policies { + if doc.Policies[i].Type == "" { + doc.Policies[i].Type = "branch" + } + } + return doc.Policies, nil +} + +func (g *ghForge) PutEnvironment(_ context.Context, env string) error { + body, _ := json.Marshal(map[string]any{ + "deployment_branch_policy": map[string]bool{"protected_branches": false, "custom_branch_policies": true}, + }) + _, err := g.api(body, "PUT", g.envPath(env, "")) + return err +} + +func (g *ghForge) AddPolicy(_ context.Context, env string, p BranchPolicy) error { + body, _ := json.Marshal(p) + _, err := g.api(body, "POST", g.envPath(env, "/deployment-branch-policies")) + return err +} + +func (g *ghForge) SecretNames(_ context.Context, env string) ([]string, error) { + out, err := g.api(nil, "", g.envPath(env, "/secrets?per_page=100")) + if err != nil { + return nil, err + } + var doc struct { + Secrets []struct { + Name string `json:"name"` + } `json:"secrets"` + } + if err := json.Unmarshal(out, &doc); err != nil { + return nil, errors.New("the secrets response could not be read as JSON") + } + names := make([]string, 0, len(doc.Secrets)) + for _, s := range doc.Secrets { + names = append(names, s.Name) + } + return names, nil +} diff --git a/internal/core/site/health.go b/internal/core/site/health.go index 4327f02af..77f3d4f76 100644 --- a/internal/core/site/health.go +++ b/internal/core/site/health.go @@ -41,7 +41,7 @@ const isolatedListCap = 20 // Without records and without a history there is no check to run, and the page // and its navigation entry are omitted (itd-140: graceful absence). func (e *explorer) hasHealth() bool { - return len(e.export.Nodes) > 0 || e.export.Authorship.Commits > 0 + return e.pages.status && (len(e.export.Nodes) > 0 || e.export.Authorship.Commits > 0) } // healthPage renders `/record/health/`: one panel per family of finding. diff --git a/internal/core/site/hosting.go b/internal/core/site/hosting.go new file mode 100644 index 000000000..7259a427e --- /dev/null +++ b/internal/core/site/hosting.go @@ -0,0 +1,44 @@ +package site + +import "regexp" + +// Hosting is the manifest's `hosting` block: which provider adapter `abcd site +// setup` routes the site through, the name of the host it creates there, and the +// custom domain it attaches, if any. It holds no credential and no account +// identifier: the credential is the machine's, read by name at setup time, and +// never enters the repository. +type Hosting struct { + Provider string `json:"provider"` + Name string `json:"name"` + Domain string `json:"domain,omitempty"` +} + +// hostNameRe is the host-name charset: lowercase letters, digits and inner +// hyphens, at most 63 characters. It is the narrowest of the providers' rules +// and is applied before the name reaches an API path or a workflow file. +var hostNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// domainRe is a lowercase DNS name of at least two labels, no scheme, no port, +// no path and no trailing dot. It reaches a provider API body and a committed +// config file, so it is held to the plainest spelling. +var domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// validate refuses a hosting block setup could not act on. +func (h *Hosting) validate(bad func(string, ...any) error) error { + if h == nil { + return nil + } + if h.Provider == "" { + return bad("hosting.provider is empty; it names the adapter setup routes the site through") + } + if _, ok := adapterNamed(h.Provider); !ok { + return bad("hosting.provider %q is not a provider abcd ships (%s)", h.Provider, providerList()) + } + if !hostNameRe.MatchString(h.Name) { + return bad("hosting.name %q is not a host name (lowercase letters, digits and inner hyphens, at most 63)", h.Name) + } + if h.Domain != "" && (len(h.Domain) > 253 || !domainRe.MatchString(h.Domain)) { + return bad("hosting.domain %q is not a plain lowercase domain name (no scheme, port or path)", h.Domain) + } + return nil +} diff --git a/internal/core/site/manifest.go b/internal/core/site/manifest.go index 6a1b370bd..64db72314 100644 --- a/internal/core/site/manifest.go +++ b/internal/core/site/manifest.go @@ -201,6 +201,13 @@ type Manifest struct { // MEASURES the unresolved references and publishes the count; the ratchet // that refuses a larger one is that verb's. Checks ManifestGate `json:"checks"` + // Pages switches pages of the closed page set off (itd-2609061543533170). + // Absent, every page renders; the set is the same for every repository and + // a switch can only take a page away, never add one. + Pages PageSwitches `json:"pages"` + // Hosting names where `abcd site setup` puts the rendered site. Absent, + // setup derives it; the build never reads it. + Hosting *Hosting `json:"hosting,omitempty"` } // BlockRef selects a span of a file by heading. @@ -440,7 +447,10 @@ func (m Manifest) validate() error { if err := m.validateDeferred(bad); err != nil { return err } - return nil + if err := m.Pages.validate(bad); err != nil { + return err + } + return m.Hosting.validate(bad) } // validateDeferred checks the keys this build does not act on yet. diff --git a/internal/core/site/pages.go b/internal/core/site/pages.go new file mode 100644 index 000000000..06be3c205 --- /dev/null +++ b/internal/core/site/pages.go @@ -0,0 +1,78 @@ +package site + +// The closed page set (itd-2609061543533170, spc-2609212141407459 scope 3). +// +// Every repository abcd renders gets the same pages abcd's own site has: the +// landing page, the record explorer, one page per record, the relationship +// graph, the timeline, the glossary and the status page. The composition +// manifest's `pages` block switches pages OFF; it cannot switch anything on, +// because the set is closed, and a key outside it is refused at load by the +// manifest's unknown-field rule like any other typo. +// +// Two pages carry the site rather than sit in it, so their switches are +// refused rather than honoured: +// +// - the landing page is the site's root; a site with no root is not a site; +// - the record pages ARE the explorer's substance, linked from every other +// explorer page, so they follow the explorer's switch and cannot be turned +// off beneath it. +// +// Switching the explorer off takes every explorer page with it (the record +// pages, the graph, the timeline, the glossary and the status page), and the +// header's link to it. Each other switch removes its page, its navigation entry +// and every link the renderer would have drawn to it. + +// PageSwitches is the `pages` block. A nil field is on. +type PageSwitches struct { + Landing *bool `json:"landing,omitempty"` + Explorer *bool `json:"explorer,omitempty"` + RecordPages *bool `json:"record_pages,omitempty"` + Graph *bool `json:"graph,omitempty"` + Timeline *bool `json:"timeline,omitempty"` + Glossary *bool `json:"glossary,omitempty"` + Status *bool `json:"status,omitempty"` +} + +// PageNames is the closed page set, in the order the manifest documents it. +var PageNames = []string{"landing", "explorer", "record_pages", "graph", "timeline", "glossary", "status"} + +// on reports a switch: absent is on. +func on(b *bool) bool { return b == nil || *b } + +// pageSet is the resolved switches the renderer consults. +type pageSet struct { + explorer, graph, timeline, glossary, status bool +} + +// resolve folds the explorer switch over the pages beneath it. +func (p PageSwitches) resolve() pageSet { + ex := on(p.Explorer) + return pageSet{ + explorer: ex, + graph: ex && on(p.Graph), + timeline: ex && on(p.Timeline), + glossary: ex && on(p.Glossary), + status: ex && on(p.Status), + } +} + +// validate refuses the two switches the set cannot honour. +func (p PageSwitches) validate(bad func(string, ...any) error) error { + if !on(p.Landing) { + return bad("pages.landing is false, but the landing page is the site's root and cannot be switched off") + } + if on(p.Explorer) && !on(p.RecordPages) { + return bad("pages.record_pages is false while the explorer is on; the record pages are what every explorer page links to, so they follow pages.explorer") + } + if !on(p.Explorer) { + for _, f := range []struct { + key string + v *bool + }{{"record_pages", p.RecordPages}, {"graph", p.Graph}, {"timeline", p.Timeline}, {"glossary", p.Glossary}, {"status", p.Status}} { + if f.v != nil && *f.v { + return bad("pages.%s is true while pages.explorer is false; it is an explorer page and goes with the explorer", f.key) + } + } + } + return nil +} diff --git a/internal/core/site/pages_test.go b/internal/core/site/pages_test.go new file mode 100644 index 000000000..b939cb85a --- /dev/null +++ b/internal/core/site/pages_test.go @@ -0,0 +1,181 @@ +package site + +// The closed page set and its per-page switches (itd-2609061543533170, +// criterion 4): the same pages for every repository, switched off per +// repository, never on to something extra. + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// withPages writes a `pages` block into the fixture's manifest. +func withPages(t *testing.T, f *fixture, block string) { + t.Helper() + p := filepath.Join(f.Root(), ManifestRelPath) + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + s := strings.Replace(string(raw), `"schema_version": 1,`, `"schema_version": 1, + "pages": `+block+`,`, 1) + if s == string(raw) { + t.Fatal("the fixture manifest has no schema_version line to anchor the pages block on") + } + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } +} + +// htmlPages returns every rendered HTML page, keyed by output-relative path. +func htmlPages(t *testing.T, out string) map[string]string { + t.Helper() + pages := map[string]string{} + err := filepath.WalkDir(out, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(p, ".html") { + return err + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + rel, _ := filepath.Rel(out, p) + pages[filepath.ToSlash(rel)] = string(raw) + return nil + }) + if err != nil { + t.Fatal(err) + } + return pages +} + +var hrefRe = regexp.MustCompile(`href="(/[^"#?]*)`) + +// linksInto names every page that links under route. +func linksInto(pages map[string]string, route string) []string { + var from []string + for name, html := range pages { + for _, m := range hrefRe.FindAllStringSubmatch(html, -1) { + if strings.HasPrefix(m[1], "/"+route) { + from = append(from, name+" → "+m[1]) + } + } + } + return from +} + +// TestEveryPageOfTheSetRendersByDefault: with no pages block, the whole set +// renders — the landing page, the explorer, a record page, the graph, the +// timeline, the glossary and the status page. +func TestEveryPageOfTheSetRendersByDefault(t *testing.T) { + f := newFixture(t) + withGlossary(t, f) + out := t.TempDir() + buildFixture(t, f, out) + pages := htmlPages(t, out) + for _, want := range []string{ + "index.html", "record/index.html", "record/adr/adr-1/index.html", "record/graph/index.html", + "record/glossary/index.html", "record/health/index.html", + } { + if _, ok := pages[want]; !ok { + t.Errorf("default build lacks %s", want) + } + } + if !strings.Contains(pages["record/index.html"], `class="panel tl"`) { + t.Error("default dashboard lacks the timeline") + } +} + +// TestASwitchedOffPageIsGoneAndNothingLinksToIt: each switch removes its page +// and every link the renderer would have drawn to it, so a reader never meets +// a link to a page the site does not have. +func TestASwitchedOffPageIsGoneAndNothingLinksToIt(t *testing.T) { + for _, tc := range []struct { + key, route string + }{ + {"graph", routeGraph}, + {"glossary", routeGlossary}, + {"status", routeHealth}, + } { + t.Run(tc.key, func(t *testing.T) { + f := newFixture(t) + withGlossary(t, f) + withPages(t, f, `{"`+tc.key+`": false}`) + out := t.TempDir() + buildFixture(t, f, out) + pages := htmlPages(t, out) + for name := range pages { + if strings.HasPrefix(name, tc.route) { + t.Errorf("%s is switched off and %s was rendered", tc.key, name) + } + } + if links := linksInto(pages, tc.route); len(links) > 0 { + t.Errorf("%s is switched off and pages still link to it: %v", tc.key, links) + } + if _, ok := pages["record/index.html"]; !ok { + t.Error("switching one page off took the explorer with it") + } + }) + } + t.Run("timeline", func(t *testing.T) { + f := newFixture(t) + withPages(t, f, `{"timeline": false}`) + out := t.TempDir() + buildFixture(t, f, out) + if strings.Contains(htmlPages(t, out)["record/index.html"], `class="panel tl"`) { + t.Error("the timeline is switched off and the dashboard still draws it") + } + }) +} + +// TestTheExplorerSwitchTakesEveryExplorerPage: with the explorer off, the +// landing page is the site, and its header no longer offers the record. +func TestTheExplorerSwitchTakesEveryExplorerPage(t *testing.T) { + f := newFixture(t) + withGlossary(t, f) + withPages(t, f, `{"explorer": false}`) + out := t.TempDir() + buildFixture(t, f, out) + pages := htmlPages(t, out) + for name := range pages { + if name != "index.html" { + t.Errorf("the explorer is off and %s was rendered", name) + } + } + for _, r := range []string{"record/", "contributors/", "references/"} { + if links := linksInto(pages, r); len(links) > 0 { + t.Errorf("the explorer is off and the landing page links into it: %v", links) + } + } +} + +// TestSwitchesTheSetCannotHonourAreRefused: the landing page and the record +// pages carry the site, a sub-page cannot be on under an explorer that is off, +// and a page outside the closed set is a typo, not a request. +func TestSwitchesTheSetCannotHonourAreRefused(t *testing.T) { + for _, block := range []string{ + `{"landing": false}`, + `{"record_pages": false}`, + `{"explorer": false, "graph": true}`, + `{"blog": true}`, + } { + t.Run(block, func(t *testing.T) { + f := newFixture(t) + withPages(t, f, block) + _, err := LoadManifest(f.Root()) + if !errors.Is(err, ErrManifestInvalid) { + t.Fatalf("pages %s: err = %v, want ErrManifestInvalid", block, err) + } + }) + } + f := newFixture(t) + withPages(t, f, `{"explorer": false, "record_pages": false}`) + if _, err := LoadManifest(f.Root()); err != nil { + t.Fatalf("switching the record pages off with the explorer is consistent: %v", err) + } +} diff --git a/internal/core/site/providers.go b/internal/core/site/providers.go new file mode 100644 index 000000000..961c1f79c --- /dev/null +++ b/internal/core/site/providers.go @@ -0,0 +1,37 @@ +package site + +import ( + "strings" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare" +) + +// adapters is the provider list: every hosting adapter abcd ships, by manifest +// key. A second provider is one implementation of hosting.Adapter and one entry +// here; the verb does not change (itd-2609061543533170 criterion 3). +var adapters = []hosting.Adapter{cloudflare.Adapter{}} + +// DefaultProvider is the provider setup routes through when the manifest names +// none. +const DefaultProvider = "cloudflare" + +// Providers is the adapter list, by name, in the order abcd ships them. +func Providers() []string { + out := make([]string, 0, len(adapters)) + for _, a := range adapters { + out = append(out, a.Name()) + } + return out +} + +func adapterNamed(name string) (hosting.Adapter, bool) { + for _, a := range adapters { + if a.Name() == name { + return a, true + } + } + return nil, false +} + +func providerList() string { return strings.Join(Providers(), ", ") } diff --git a/internal/core/site/setup.go b/internal/core/site/setup.go new file mode 100644 index 000000000..6de9d718b --- /dev/null +++ b/internal/core/site/setup.go @@ -0,0 +1,759 @@ +package site + +// `abcd site setup` (itd-2609061543533170, spc-2609212141407459 scope 1): one +// verb that takes a managed repository's site from the checkout to a live +// address. +// +// It works in three stages, in this order, and each stage's outcome is +// reported whether or not the next one runs: +// +// 1. THE REPOSITORY. The composition (.abcd/site.json, derived from the +// identity block and the documentation), the site's static inputs under +// site-src/, the render-on-release-then-deploy workflow, and the +// provider's host configuration. Written through the launch scaffold's +// writer, so a file already current is not rewritten, a file the +// repository owns once it exists (the composition and the static inputs) +// is kept, and machinery that drifted refuses the whole stage — with +// nothing written and nothing remote attempted — unless it is confirmed. +// 2. THE FORGE. The two deployment environments the workflow runs in, +// restricted to the default branch and release tags, created or corrected +// through the forge's API as the person who invoked the verb (adr-44: a +// remote write only through a dedicated verb, invoked AND confirmed). +// 3. THE HOST. With a hosting credential on this machine, the provider +// adapter creates the host, routes the domain to it and reports the live +// address, again only after a confirmation that names the changes. +// Without one, the stage stops and says exactly what remains. +// +// What never happens: a secret's value is read or written anywhere (the +// forge's environment secrets are the person's step, printed as the exact +// command), the credential is written into the repository or the report, or a +// file outside the list above is touched. + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "text/template" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/credential" + "github.com/intentdriven/abcd/internal/core/launch/scaffold" + "github.com/intentdriven/abcd/internal/core/positioning" + "github.com/intentdriven/abcd/internal/gitutil" +) + +//go:embed setupsrc/ui.json setupsrc/site.css setupsrc/site.js setupsrc/record.js setupsrc/headers setupsrc/site.yml.tmpl +var setupSources embed.FS + +// seedNames are the static inputs a managed repository is seeded with under +// site-src/: byte copies of abcd's own (TestTheSeedSourcesAreAbcdsOwn). The +// redirects map is not among them: it is abcd's own URL history. +var seedNames = []string{"ui.json", "site.css", "site.js", "record.js", "headers"} + +// SiteWorkflowRelPath is the workflow setup writes. +const SiteWorkflowRelPath = ".github/workflows/site.yml" + +// The two deployment environments the workflow runs in. +const ( + EnvRender = "site-render" + EnvDeploy = "site" +) + +// abcdReleaseRepo is where the workflow downloads the abcd binary it renders +// with, and whose release workflow must have signed it. +const abcdReleaseRepo = "intentdriven/abcd" + +// Overall statuses. +const ( + // StatusChanged: something was written, locally or remotely. + StatusChanged = "changed" + // StatusNoChange: nothing was written, because every file, environment and + // host was already current or is left to a remaining step (an existing + // environment setup does not rewrite). + StatusNoChange = "no_change" + // StatusDeclined: a confirmation was declined, so a remote write did not + // happen. + StatusDeclined = "declined" + // StatusRefused: a gate or a failure stopped a stage. + StatusRefused = "refused" +) + +// Remote outcome statuses (an environment). +const ( + RemoteCurrent = "current" + RemoteWritten = "written" + RemoteDeclined = "declined" + RemoteRefused = "refused" + RemoteUnreachable = "unreachable" + RemoteNotReached = "not_reached" + // RemoteUnrestricted: the environment exists and admits more than the + // default branch and release tags. The forge's environment write replaces + // an environment's whole protection set (its required reviewers and wait + // timer with it), so setup never rewrites one that exists: restricting it + // is a remaining step. + RemoteUnrestricted = "unrestricted" +) + +// Host outcome statuses. +const ( + HostCurrent = "current" + HostWritten = "written" + HostDeclined = "declined" + HostRefused = "refused" + HostNoCredential = "no_credential" + HostNotReached = "not_reached" +) + +// ErrNotManaged refuses a folder abcd does not manage. +var ErrNotManaged = errors.New("site setup: this is not a repository abcd manages (run `abcd ahoy install` first)") + +// Asker confirms a remote write. A nil Asker declines. +type Asker interface { + Confirm(question string) bool +} + +// SetupRequest is one run of the verb. +type SetupRequest struct { + // RepoRoot is any directory inside the repository. + RepoRoot string + // Name and Domain seed the hosting block when the composition has none. + // Name defaults to the repository's own name. + Name, Domain string + // Confirm replaces machinery that drifted from what setup writes. + Confirm bool + // Asker confirms each remote write; nil declines them all. + Asker Asker + // Forge is the repository's forge; nil resolves GitHub through gh. + Forge Forge + // Credentials resolves the hosting credential by name; nil is this + // machine's store. + Credentials credential.Source + // Adapter overrides the provider the composition names (tests point it at + // a fake); nil resolves the composition's provider from the list. + Adapter hosting.Adapter + Context context.Context +} + +// EnvironmentOutcome is one environment's result. +type EnvironmentOutcome struct { + Name string `json:"name"` + Status string `json:"status"` + Changes []string `json:"changes,omitempty"` +} + +// HostOutcome is the host stage's result. +type HostOutcome struct { + Provider string `json:"provider"` + Name string `json:"name"` + Domain string `json:"domain,omitempty"` + Status string `json:"status"` + Changes []string `json:"changes,omitempty"` + Address string `json:"address,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// SetupResult is what the verb did, stage by stage, and what remains. +type SetupResult struct { + Status string `json:"status"` + Repo string `json:"repo,omitempty"` + Files []scaffold.FileOutcome `json:"files"` + Environments []EnvironmentOutcome `json:"environments"` + Host HostOutcome `json:"host"` + // Remaining are the exact steps left for the person, in order. + Remaining []string `json:"remaining,omitempty"` + // Notes say what the verb deliberately did not do, and why. + Notes []string `json:"notes,omitempty"` +} + +// Setup runs the verb. It returns an error only for a gate that stops it before +// any stage runs; every later failure is a refused stage in the result. +func Setup(req SetupRequest) (SetupResult, error) { + ctx := req.Context + if ctx == nil { + ctx = context.Background() + } + root, err := setupRoot(req.RepoRoot) + if err != nil { + return SetupResult{}, err + } + det, err := ahoy.Detect(root) + if err != nil { + return SetupResult{}, fmt.Errorf("site setup: could not classify this folder: %w", err) + } + if det.FolderKind != ahoy.ManagedRepo { + return SetupResult{}, ErrNotManaged + } + + manifest, manifestBytes, err := setupComposition(root, req) + if err != nil { + return SetupResult{}, err + } + hostingBlock := *manifest.Hosting + adapter := req.Adapter + if adapter == nil { + a, ok := adapterNamed(hostingBlock.Provider) + if !ok { + return SetupResult{}, fmt.Errorf("site setup: hosting.provider %q is not a provider abcd ships (%s)", hostingBlock.Provider, providerList()) + } + adapter = a + } + s := hosting.Site{Name: hostingBlock.Name, Domain: hostingBlock.Domain} + branch, _ := scaffold.DeriveRepoFacts(root) + + res := SetupResult{Host: HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain, Status: HostNotReached}} + + // Stage 1: the repository. + planned, err := plannedFiles(root, manifest, manifestBytes, branch, adapter, s) + if err != nil { + return SetupResult{}, err + } + outcomes, wrote, _, werr := scaffold.WriteFiles(root, planned, req.Confirm) + res.Files = outcomes + if werr != nil { + res.Status = StatusRefused + if errors.Is(werr, scaffold.ErrScaffoldBlocked) { + res.Notes = append(res.Notes, "a file setup owns was edited by hand, so nothing was written and nothing remote was attempted; "+ + "re-run with --confirm to replace it") + } else { + res.Notes = append(res.Notes, "a write failed, so nothing remote was attempted: "+scrubRoot(werr, root)) + } + for _, env := range []string{EnvRender, EnvDeploy} { + res.Environments = append(res.Environments, EnvironmentOutcome{Name: env, Status: RemoteNotReached}) + } + return res, nil + } + changed := wrote > 0 + declined, refused := false, false + + // Stage 2: the forge. + forge := req.Forge + if forge == nil { + f, ferr := GitHubForge(root) + if ferr != nil { + res.Notes = append(res.Notes, "the forge is not reachable from this checkout, so the environments were not created: "+ferr.Error()) + } else { + forge = f + } + } + envOK := true + // Without this step ahead of the secret steps, a person following them + // sets the token first, and the workflow's first run creates the deploy + // environment with no policy at all: deployable from any branch. + createStep := fmt.Sprintf("create the forge environments %s and %s, each admitting only branch %s and tags v*, "+ + "as the workflow's header describes, before setting any deploy secret", EnvRender, EnvDeploy, branch) + if forge == nil { + envOK = false + for _, env := range []string{EnvRender, EnvDeploy} { + res.Environments = append(res.Environments, EnvironmentOutcome{Name: env, Status: RemoteUnreachable}) + } + res.Remaining = append(res.Remaining, createStep) + } else { + res.Repo = forge.Repo() + envs, st, note, steps := setupEnvironments(ctx, forge, branch, req.Asker) + res.Environments = envs + res.Remaining = append(res.Remaining, steps...) + switch st { + case RemoteWritten: + changed = true + case RemoteDeclined: + declined, envOK = true, false + res.Remaining = append(res.Remaining, "re-run `abcd site setup` and confirm, to create the environments "+EnvRender+" and "+EnvDeploy) + case RemoteRefused: + refused, envOK = true, false + res.Notes = append(res.Notes, note) + res.Remaining = append(res.Remaining, createStep) + } + } + + // Stage 3: the host. A forge failure stops here: the environments are what + // the deploy runs in, and a host created for a deploy that cannot run is a + // half-built site. + if refused { + res.Host.Detail = "not reached: the forge stage failed" + } else { + hc, hdeclined, hrefused := setupHost(ctx, adapter, s, req) + res.Host = hc + switch { + case hrefused: + refused = true + case hdeclined: + declined = true + case hc.Status == HostWritten: + changed = true + } + if hc.Status == HostNoCredential { + step := fmt.Sprintf("store a %s API token under the name %s in %s (mode 0600) and re-run `abcd site setup`, "+ + "or create the host %s", adapter.Name(), adapter.CredentialName(), credential.StorePath, s.Name) + if s.Domain != "" { + step += " and route " + s.Domain + " to it" + } + res.Remaining = append(res.Remaining, step+" in the provider's own console") + } + if hdeclined { + res.Remaining = append(res.Remaining, "re-run `abcd site setup` and confirm, to create and route the host") + } + } + + // The deploy environment's secrets: never set by abcd (a value would pass + // through it), but their presence is readable by name. + res.Remaining = append(res.Remaining, secretSteps(ctx, forge, envOK, adapter, &res)...) + + if wrote > 0 { + var paths []string + for _, f := range res.Files { + if f.Status == scaffold.StatusWritten { + paths = append(paths, f.Path) + } + } + res.Remaining = append([]string{"commit the written files and push them to " + branch + ": `git add " + + strings.Join(paths, " ") + "`"}, res.Remaining...) + } + res.Notes = append(res.Notes, "the site renders and deploys on the next published release, whether a person or "+ + "the release workflows publish it; `gh workflow run site.yml` deploys the latest one now") + + switch { + case refused: + res.Status = StatusRefused + case declined: + res.Status = StatusDeclined + case changed: + res.Status = StatusChanged + default: + res.Status = StatusNoChange + } + return res, nil +} + +// setupRoot anchors the run at the working-tree root. +func setupRoot(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + top, err := gitutil.Run(abs, "rev-parse", "--show-toplevel") + if err != nil || strings.TrimSpace(top) == "" { + return "", errors.New("site setup: this is not inside a git checkout") + } + return strings.TrimSpace(top), nil +} + +// scrubRoot keeps the checkout's absolute path out of a message. +func scrubRoot(err error, root string) string { + return strings.ReplaceAll(err.Error(), root, ".") +} + +// setupComposition returns the composition setup works from, and the bytes to +// write when the repository has none yet (nil when it has one). +func setupComposition(root string, req SetupRequest) (Manifest, []byte, error) { + bad := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrManifestInvalid, fmt.Sprintf(format, args...)) + } + m, err := LoadManifest(root) + switch { + case err == nil: + if m.Hosting == nil { + h := &Hosting{Provider: DefaultProvider, Name: req.Name, Domain: req.Domain} + if h.Name == "" { + h.Name = defaultHostName(root) + } + if err := h.validate(bad); err != nil { + return Manifest{}, nil, err + } + if req.Domain != "" || req.Name != "" { + // The composition is the repository's own, so setup does not + // rewrite it; the domain has to live in it for the next run to + // route the same one. + blk, _ := json.Marshal(h) + return Manifest{}, nil, fmt.Errorf("site setup: %s exists and declares no hosting block; add `\"hosting\": %s` to it rather than passing --name or --domain", + ManifestRelPath, blk) + } + m.Hosting = h + } else if (req.Name != "" && req.Name != m.Hosting.Name) || (req.Domain != "" && req.Domain != m.Hosting.Domain) { + return Manifest{}, nil, fmt.Errorf("site setup: %s already names the host %q and domain %q; edit its hosting block to change them", + ManifestRelPath, m.Hosting.Name, m.Hosting.Domain) + } + return m, nil, nil + case !os.IsNotExist(err): + return Manifest{}, nil, err + } + + cfg, ok, cerr := positioning.LoadConfig(root) + if cerr != nil { + return Manifest{}, nil, fmt.Errorf("site setup: the identity pointer cannot be read: %w", cerr) + } + if !ok { + return Manifest{}, nil, errors.New("site setup: this repository has recorded no identity block, and the landing page's hero is composed from it; record one first with `abcd identity init`") + } + hero := "" + for _, p := range []string{"docs/README.md", "docs/index.md"} { + if fi, serr := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); serr == nil && fi.Mode().IsRegular() { + hero = p + break + } + } + if hero == "" { + return Manifest{}, nil, errors.New("site setup: the landing page is composed from the documentation, and this repository has none; write docs/README.md (its first heading and paragraph become the hero) and re-run") + } + name := req.Name + if name == "" { + name = defaultHostName(root) + } + m = Manifest{ + SchemaVersion: 1, + Purpose: "Composition manifest for this repository's site, written by `abcd site setup`. It names WHERE each block " + + "of the site comes from and carries no prose. `pages` switches pages of the closed set off; `hosting` names " + + "where setup puts the site.", + Identity: BlockRef{File: cfg.Block.File, Heading: cfg.Block.Heading}, + UIStrings: "site-src/ui.json", + Home: Home{ + Hero: Hero{Page: hero, Figure: figureFirstImage}, + Chapters: []Chapter{{Letter: "a", Page: hero, Layout: LayoutProse}}, + }, + Hosting: &Hosting{Provider: DefaultProvider, Name: name, Domain: req.Domain}, + } + if err := m.validate(); err != nil { + return Manifest{}, nil, err + } + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return Manifest{}, nil, err + } + return m, append(raw, '\n'), nil +} + +// hostCharRe is what a repository name keeps on its way to a host name. +var hostCharRe = regexp.MustCompile(`[^a-z0-9-]+`) + +// defaultHostName is the repository's own name, as a host name: the origin's +// repository segment when it names one, the checkout's directory otherwise. +func defaultHostName(root string) string { + raw := filepath.Base(root) + if repo, err := ahoy.GitHubRepo(root); err == nil { + if _, name, ok := strings.Cut(repo, "/"); ok { + raw = name + } + } + n := strings.Trim(hostCharRe.ReplaceAllString(strings.ToLower(raw), "-"), "-") + if len(n) > 63 { + n = strings.TrimRight(n[:63], "-") + } + return n +} + +// plannedFiles is the repository half. +func plannedFiles(root string, m Manifest, manifestBytes []byte, branch string, adapter hosting.Adapter, s hosting.Site) ([]scaffold.PlannedFile, error) { + var planned []scaffold.PlannedFile + if manifestBytes != nil { + planned = append(planned, scaffold.PlannedFile{Path: ManifestRelPath, Data: manifestBytes, Seed: true}) + } + for _, name := range seedNames { + data, err := setupSources.ReadFile("setupsrc/" + name) + if err != nil { + return nil, err + } + rel := "site-src/" + name + if name == "ui.json" { + rel = m.UIStrings + } + planned = append(planned, scaffold.PlannedFile{Path: rel, Data: data, Seed: true}) + } + wf, err := renderSiteWorkflow(branch, adapter) + if err != nil { + return nil, err + } + planned = append(planned, scaffold.PlannedFile{Path: SiteWorkflowRelPath, Data: wf}) + rel, data := adapter.HostConfig(s) + planned = append(planned, scaffold.PlannedFile{Path: rel, Data: data}) + // The writer resolves each path under the root by name, so a symlinked + // directory on the way would carry a write out of the repository. Refuse + // the run before any write rather than follow one. + for _, p := range planned { + if err := refuseSymlinkedAncestor(root, p.Path); err != nil { + return nil, err + } + } + return planned, nil +} + +// refuseSymlinkedAncestor refuses rel when a directory between root and its +// leaf is a symlink. +func refuseSymlinkedAncestor(root, rel string) error { + parts := strings.Split(rel, "/") + cur := root + for _, part := range parts[:len(parts)-1] { + cur = filepath.Join(cur, part) + fi, err := os.Lstat(cur) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("site setup: %s could not be examined", rel) + } + if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { + return fmt.Errorf("site setup: a directory on the way to %s is a symlink or not a directory, so nothing is written through it", rel) + } + } + return nil +} + +// renderSiteWorkflow renders the workflow for the repository's default branch +// and the provider's deploy step. +func renderSiteWorkflow(branch string, adapter hosting.Adapter) ([]byte, error) { + raw, err := setupSources.ReadFile("setupsrc/site.yml.tmpl") + if err != nil { + return nil, err + } + tmpl, err := template.New("site.yml").Delims("<%", "%>").Option("missingkey=error").Parse(string(raw)) + if err != nil { + return nil, err + } + rel, _ := adapter.HostConfig(hosting.Site{Name: "x"}) + var buf bytes.Buffer + err = tmpl.Execute(&buf, map[string]any{ + // The branch comes from scaffold.DeriveRepoFacts, allowlisted there + // against every YAML metacharacter. + "DefaultBranch": branch, + "Provider": adapter.Name(), + "Secrets": adapter.Secrets(), + "DeployStep": adapter.DeployStep(), + "HostConfig": rel, + "AbcdRepo": abcdReleaseRepo, + }) + return buf.Bytes(), err +} + +// desiredPolicies are the refs each environment admits. +func desiredPolicies(branch string) []BranchPolicy { + return []BranchPolicy{{Name: branch, Type: "branch"}, {Name: "v*", Type: "tag"}} +} + +// setupEnvironments reads both environments, asks once for every change, and +// applies them in order, stopping at the first failure. It returns the +// outcomes, the stage's status, a note on a refusal, and the steps left for +// the person. +// +// Only an ABSENT environment is written through the forge's environment +// endpoint: that write replaces the environment's whole protection set, so on +// an existing one it would drop the required reviewers and wait timer a person +// put there. An existing environment on custom policies only gains the +// policies it lacks (their own endpoint, which touches nothing else); one that +// admits more refs than that, through its protection mode or through a custom +// rule beyond the two it needs, is left as it is, and restricting it is a step. +func setupEnvironments(ctx context.Context, forge Forge, branch string, asker Asker) ([]EnvironmentOutcome, string, string, []string) { + names := []string{EnvRender, EnvDeploy} + outcomes := make([]EnvironmentOutcome, len(names)) + var steps []string + fail := func(what string, err error) ([]EnvironmentOutcome, string, string, []string) { + for i := range outcomes { + if outcomes[i].Status == "" || outcomes[i].Status == RemoteNotReached { + outcomes[i] = EnvironmentOutcome{Name: names[i], Status: RemoteRefused, Changes: outcomes[i].Changes} + } + } + return outcomes, RemoteRefused, "the forge refused " + what + ", so the remaining forge and host steps were not attempted: " + err.Error(), steps + } + envs, err := forge.Environments(ctx) + if err != nil { + return fail("the environment read", err) + } + type plan struct { + put bool + missing []BranchPolicy + } + plans := make([]plan, len(names)) + var all []string + for i, env := range names { + outcomes[i] = EnvironmentOutcome{Name: env, Status: RemoteNotReached} + st, exists := envs[env] + if exists && (!st.CustomBranchPolicies || st.ProtectedBranches) { + outcomes[i].Status = RemoteUnrestricted + steps = append(steps, fmt.Sprintf("restrict the existing environment %s to branch %s and tags v*: in the forge's settings "+ + "for %s, admit selected branches and tags only, then re-run `abcd site setup` to add the two rules. "+ + "abcd does not rewrite an environment that exists, because the forge's environment write replaces its "+ + "protection rules, required reviewers included", env, branch, env)) + continue + } + var have []BranchPolicy + if exists { + if have, err = forge.Policies(ctx, env); err != nil { + return fail("the deployment policy read for "+env, err) + } + // A rule of the environment's own beyond the two it needs admits + // more refs than they do (a branch `*`, for one), so the + // environment is as open as one on no custom rules at all. + var extra []string + for _, h := range have { + if !containsPolicy(desiredPolicies(branch), h) { + extra = append(extra, h.Type+" "+h.Name) + } + } + if len(extra) > 0 { + outcomes[i].Status = RemoteUnrestricted + steps = append(steps, fmt.Sprintf("restrict the existing environment %s to branch %s and tags v*: in the forge's settings "+ + "for %s, remove its deployment rules for %s, then re-run `abcd site setup` to add any rule it lacks. "+ + "abcd does not remove a rule a person put on an environment that exists", env, branch, env, strings.Join(extra, ", "))) + continue + } + } + p := plan{put: !exists} + for _, want := range desiredPolicies(branch) { + if !containsPolicy(have, want) { + p.missing = append(p.missing, want) + } + } + plans[i] = p + if !exists { + outcomes[i].Changes = append(outcomes[i].Changes, "create "+env+" admitting named branches and tags only") + } + for _, m := range p.missing { + outcomes[i].Changes = append(outcomes[i].Changes, "admit "+m.Type+" "+m.Name+" to "+env) + } + all = append(all, outcomes[i].Changes...) + if len(outcomes[i].Changes) == 0 { + outcomes[i].Status = RemoteCurrent + } + } + if len(all) == 0 { + return outcomes, RemoteCurrent, "", steps + } + if asker == nil || !asker.Confirm("Change the deployment environments on "+forge.Repo()+"? ("+strings.Join(all, "; ")+")") { + for i := range outcomes { + if outcomes[i].Status != RemoteCurrent && outcomes[i].Status != RemoteUnrestricted { + outcomes[i].Status = RemoteDeclined + } + } + return outcomes, RemoteDeclined, "", steps + } + for i, env := range names { + if outcomes[i].Status == RemoteCurrent || outcomes[i].Status == RemoteUnrestricted { + continue + } + if plans[i].put { + if err := forge.PutEnvironment(ctx, env); err != nil { + return fail("the environment write for "+env, err) + } + } + for _, m := range plans[i].missing { + if err := forge.AddPolicy(ctx, env, m); err != nil { + return fail("the deployment policy write for "+env, err) + } + } + outcomes[i].Status = RemoteWritten + } + return outcomes, RemoteWritten, "", steps +} + +func containsPolicy(have []BranchPolicy, want BranchPolicy) bool { + for _, h := range have { + if h.Name == want.Name && h.Type == want.Type { + return true + } + } + return false +} + +// setupHost is the host stage. +func setupHost(ctx context.Context, adapter hosting.Adapter, s hosting.Site, req SetupRequest) (out HostOutcome, declined, refused bool) { + out = HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain} + src := req.Credentials + if src == nil { + src = credential.UserMachine() + } + token, err := src.Resolve(adapter.CredentialName()) + if errors.Is(err, credential.ErrNotSet) { + out.Status = HostNoCredential + out.Detail = "no " + adapter.CredentialName() + " credential on this machine, so the host was not contacted" + return out, false, false + } + if err != nil { + out.Status = HostRefused + out.Detail = err.Error() + return out, false, true + } + p := adapter.Connect(token) + refuse := func(err error) (HostOutcome, bool, bool) { + out.Status = HostRefused + out.Detail = strings.ReplaceAll(err.Error(), token, "[credential]") + return out, false, true + } + st, err := p.Inspect(ctx, s) + if err != nil { + return refuse(err) + } + if !st.Exists { + out.Changes = append(out.Changes, "create "+adapter.Name()+" host "+s.Name) + } + if !st.Routed { + out.Changes = append(out.Changes, "route "+s.Domain+" to "+s.Name) + } + if len(out.Changes) > 0 { + if req.Asker == nil || !req.Asker.Confirm("Change the host on "+adapter.Name()+"? ("+strings.Join(out.Changes, "; ")+")") { + out.Status = HostDeclined + return out, true, false + } + if !st.Exists { + if err := p.Create(ctx, s); err != nil { + return refuse(err) + } + } + if !st.Routed { + if err := p.Route(ctx, s); err != nil { + return refuse(err) + } + } + } + addr, err := p.Address(ctx, s) + if err != nil { + return refuse(err) + } + out.Address = addr + out.Status = HostCurrent + if len(out.Changes) > 0 { + out.Status = HostWritten + } + return out, false, false +} + +// secretSteps names the deploy environment's secrets that are not set, as the +// exact commands that set them. gh reads the value from the terminal, so it +// never passes through abcd or a shell history. +func secretSteps(ctx context.Context, forge Forge, envOK bool, adapter hosting.Adapter, res *SetupResult) []string { + want := adapter.Secrets() + missing := want + repo := res.Repo + if forge != nil && envOK { + names, err := forge.SecretNames(ctx, EnvDeploy) + if err != nil { + res.Notes = append(res.Notes, "the "+EnvDeploy+" environment's secret names could not be read, so every one is listed: "+err.Error()) + } else { + have := map[string]bool{} + for _, n := range names { + have[n] = true + } + missing = nil + for _, w := range want { + if !have[w] { + missing = append(missing, w) + } + } + } + } + sort.Strings(missing) + var steps []string + for _, name := range missing { + cmd := "gh secret set " + name + " --env " + EnvDeploy + if repo != "" { + cmd += " --repo " + repo + } + steps = append(steps, "set the deploy secret: `"+cmd+"`") + } + return steps +} diff --git a/internal/core/site/setup_test.go b/internal/core/site/setup_test.go new file mode 100644 index 000000000..90f20a3fc --- /dev/null +++ b/internal/core/site/setup_test.go @@ -0,0 +1,817 @@ +package site + +// `abcd site setup` end to end (itd-2609061543533170, spc-2609212141407459): +// the writes against a fixture managed repository, the forge against an +// in-process fake, the provider against cloudflaretest's fake API. Nothing here +// reaches a network. + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare" + "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare/cloudflaretest" + "github.com/intentdriven/abcd/internal/core/credential" + "github.com/intentdriven/abcd/internal/gittest" +) + +// --- the fakes ------------------------------------------------------------- + +type fakeForge struct { + envs map[string]EnvironmentState + policies map[string][]BranchPolicy + secrets map[string][]string + fail map[string]error + writes []string + // protection is each environment's protection rules other than its + // deployment policy (required reviewers, a wait timer). The forge's + // environment write replaces the whole set, so PutEnvironment clears it, + // as the real endpoint would. + protection map[string][]string +} + +func newFakeForge() *fakeForge { + return &fakeForge{ + envs: map[string]EnvironmentState{}, policies: map[string][]BranchPolicy{}, + secrets: map[string][]string{}, fail: map[string]error{}, protection: map[string][]string{}, + } +} + +func (f *fakeForge) Repo() string { return "example-owner/example-site" } + +func (f *fakeForge) Environments(context.Context) (map[string]EnvironmentState, error) { + if err := f.fail["Environments"]; err != nil { + return nil, err + } + out := map[string]EnvironmentState{} + for k, v := range f.envs { + out[k] = v + } + return out, nil +} + +func (f *fakeForge) Policies(_ context.Context, env string) ([]BranchPolicy, error) { + if err := f.fail["Policies"]; err != nil { + return nil, err + } + return append([]BranchPolicy(nil), f.policies[env]...), nil +} + +func (f *fakeForge) PutEnvironment(_ context.Context, env string) error { + if err := f.fail["PutEnvironment"]; err != nil { + return err + } + f.writes = append(f.writes, "put "+env) + f.envs[env] = EnvironmentState{CustomBranchPolicies: true} + delete(f.protection, env) + return nil +} + +func (f *fakeForge) AddPolicy(_ context.Context, env string, p BranchPolicy) error { + if err := f.fail["AddPolicy"]; err != nil { + return err + } + f.writes = append(f.writes, "policy "+env+" "+p.Type+" "+p.Name) + f.policies[env] = append(f.policies[env], p) + return nil +} + +func (f *fakeForge) SecretNames(_ context.Context, env string) ([]string, error) { + if err := f.fail["SecretNames"]; err != nil { + return nil, err + } + return f.secrets[env], nil +} + +type answer bool + +func (a answer) Confirm(string) bool { return bool(a) } + +type fixedCredential struct { + value string + err error +} + +func (c fixedCredential) Resolve(string) (string, error) { return c.value, c.err } + +// --- the managed repository -------------------------------------------------- + +// newManagedRepo builds a small repository abcd manages: the marker block, an +// identity block and its pointer, one documentation page, a record with a +// glossary, and a github.com origin. +func newManagedRepo(t *testing.T) *gittest.Repo { + t.Helper() + t.Setenv("HOME", t.TempDir()) + r := gittest.NewRepo(t) + r.Write("AGENTS.md", "# Example\n\n\nmanaged\n\n") + r.Write(".abcd/positioning.json", `{ + "schema_version": 1, + "block": {"file": ".abcd/development/IDENTITY.md", "heading": "Identity (canonical)"}, + "severity": "warn", + "surfaces": [] +} +`) + r.Write(".abcd/development/IDENTITY.md", "# Identity\n\n## Identity (canonical)\n\n"+ + "- **Title:** Example Site\n- **Tagline:** An example repository.\n- **Pitch:** It exists to be rendered.\n") + r.Write("docs/README.md", "# Example Site\n\nThe example repository's documentation.\n\n## Why\n\nBecause a test needs one.\n") + r.Write(".abcd/record-lint.json", `{ + "roots": [".abcd/development"], + "banned_tokens": [], + "rules": {"record_schema": {"enabled": true, "severity": "blocker", "record_stores": { + "adr": ".abcd/development/decisions/adrs" + }}} +} +`) + r.Write(".abcd/development/decisions/adrs/0001-a-decision.md", `--- +id: adr-1 +slug: a-decision +status: accepted +date: 2026-01-02 +supersedes: null +superseded_by: null +related_intents: [] +related_rfcs: [] +related_adrs: [] +--- + +# ADR-1: A decision + +The decision's body names a phase. +`) + r.Write(".abcd/development/brief/glossary/README.md", "# Glossary\n\nThe example glossary.\n") + r.Write(".abcd/development/brief/glossary/core/README.md", "# core\n\nThe core context.\n") + r.Write(".abcd/development/brief/glossary/core/phase.md", glossaryTermFile( + "phase", "core", "An ordered stretch of work.", `[]`, "A phase is a stretch of work.\n")) + r.Commit("the example repository") + r.Git("remote", "add", "origin", "https://github.com/example-owner/example-site.git") + return r +} + +type harness struct { + repo *gittest.Repo + forge *fakeForge + host *cloudflaretest.Server + cred credential.Source + ask Asker +} + +func newHarness(t *testing.T) *harness { + t.Helper() + return &harness{ + repo: newManagedRepo(t), + forge: newFakeForge(), + host: cloudflaretest.New(t), + cred: fixedCredential{err: credential.ErrNotSet}, + ask: answer(true), + } +} + +func (h *harness) withCredential() *harness { + h.cred = fixedCredential{value: cloudflaretest.Token} + return h +} + +func (h *harness) run(t *testing.T, mutate ...func(*SetupRequest)) SetupResult { + t.Helper() + req := SetupRequest{ + RepoRoot: h.repo.Root(), + Asker: h.ask, + Forge: h.forge, + Credentials: h.cred, + Adapter: cloudflare.Adapter{BaseURL: h.host.URL}, + Context: context.Background(), + } + for _, m := range mutate { + m(&req) + } + res, err := Setup(req) + if err != nil { + t.Fatalf("setup: %v", err) + } + return res +} + +func withDomain(d string) func(*SetupRequest) { return func(r *SetupRequest) { r.Domain = d } } + +// setupFiles is the whole repository half, in the order the verb reports it. +var setupFiles = []string{ + ManifestRelPath, "site-src/ui.json", "site-src/site.css", "site-src/site.js", "site-src/record.js", + "site-src/headers", SiteWorkflowRelPath, "wrangler.jsonc", +} + +func fileStatuses(res SetupResult) map[string]string { + out := map[string]string{} + for _, f := range res.Files { + out[f.Path] = string(f.Status) + } + return out +} + +// --- criterion 1: no credential ------------------------------------------------ + +func TestSetupWithoutACredentialWritesTheRepositoryHalfAndSaysWhatRemains(t *testing.T) { + h := newHarness(t) + res := h.run(t) + + got := fileStatuses(res) + for _, p := range setupFiles { + if got[p] != "written" { + t.Errorf("%s: %q, want written", p, got[p]) + } + if _, err := os.Stat(filepath.Join(h.repo.Root(), p)); err != nil { + t.Errorf("%s reported written and is not on disk: %v", p, err) + } + } + for _, env := range []string{EnvRender, EnvDeploy} { + pol := h.forge.policies[env] + want := []BranchPolicy{{Name: "main", Type: "branch"}, {Name: "v*", Type: "tag"}} + if !reflect.DeepEqual(pol, want) { + t.Errorf("environment %s policies = %v, want %v", env, pol, want) + } + if !h.forge.envs[env].CustomBranchPolicies { + t.Errorf("environment %s is not restricted to custom policies", env) + } + } + if res.Host.Status != HostNoCredential { + t.Fatalf("host status = %q, want %q", res.Host.Status, HostNoCredential) + } + if n := len(h.host.CallLog()); n != 0 { + t.Fatalf("with no credential the provider was called %d times: %v", n, h.host.CallLog()) + } + remaining := strings.Join(res.Remaining, "\n") + for _, want := range []string{ + cloudflare.CredentialName, credential.StorePath, + "gh secret set CLOUDFLARE_API_TOKEN --env site --repo example-owner/example-site", + "gh secret set CLOUDFLARE_ACCOUNT_ID --env site --repo example-owner/example-site", + "git add", + } { + if !strings.Contains(remaining, want) { + t.Errorf("the remaining steps do not say %q:\n%s", want, remaining) + } + } + if res.Status != StatusChanged { + t.Fatalf("status = %q, want %q", res.Status, StatusChanged) + } +} + +// --- criterion 2: with a credential --------------------------------------------- + +func TestSetupWithACredentialCreatesRoutesAndReportsTheHost(t *testing.T) { + h := newHarness(t).withCredential() + before := h.repo.Git("status", "--porcelain") + if strings.TrimSpace(before) != "" { + t.Fatalf("precondition: the fixture is dirty:\n%s", before) + } + res := h.run(t, withDomain("docs.example.com")) + + if res.Host.Status != HostWritten || res.Host.Address != "https://docs.example.com" { + t.Fatalf("host = %+v, want written at https://docs.example.com", res.Host) + } + h.host.Mu.Lock() + worker, routed := h.host.Workers["example-site"], h.host.Domains["docs.example.com"] + auth := append([]string(nil), h.host.Auth...) + h.host.Mu.Unlock() + if !worker || routed != "example-site" { + t.Fatalf("the host holds worker=%v domain→%q", worker, routed) + } + for _, a := range auth { + if a != "Bearer "+cloudflaretest.Token { + t.Fatalf("a request carried an unexpected Authorization header") + } + } + + // Nothing is written into the repository but the files above, and none of + // them carries the credential. + var changed []string + for _, line := range strings.Split(strings.TrimRight(h.repo.Git("status", "--porcelain", "-uall"), "\n"), "\n") { + if line == "" { + continue + } + changed = append(changed, strings.TrimSpace(line[2:])) + } + sort.Strings(changed) + want := append([]string(nil), setupFiles...) + sort.Strings(want) + if !reflect.DeepEqual(changed, want) { + t.Fatalf("the repository changed in\n %v\nwant exactly\n %v", changed, want) + } + for _, p := range setupFiles { + raw, err := os.ReadFile(filepath.Join(h.repo.Root(), p)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), cloudflaretest.Token) { + t.Fatalf("%s carries the credential", p) + } + } + for _, line := range append(append([]string{}, res.Remaining...), res.Notes...) { + if strings.Contains(line, cloudflaretest.Token) { + t.Fatal("the report carries the credential") + } + } + if !strings.Contains(string(mustRead(t, h.repo.Root(), ManifestRelPath)), `"domain": "docs.example.com"`) { + t.Fatal("the composition does not record the domain the host was routed for") + } +} + +func mustRead(t *testing.T, root, rel string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + return raw +} + +// --- criterion 3: the seam ------------------------------------------------------- + +func TestTheProviderListHasOneAdapterBehindTheSeam(t *testing.T) { + if got := Providers(); !reflect.DeepEqual(got, []string{"cloudflare"}) { + t.Fatalf("providers = %v, want exactly [cloudflare]", got) + } + a, ok := adapterNamed("cloudflare") + if !ok { + t.Fatal("the listed provider does not resolve") + } + var _ hosting.Adapter = a +} + +// --- criterion 4: the page set for any managed repository ------------------------ + +func TestSetupGivesAManagedRepositoryTheWholePageSet(t *testing.T) { + h := newHarness(t) + h.run(t) + out := t.TempDir() + if _, err := Build(Request{RepoRoot: h.repo.Root(), OutDir: out, Stamp: fixtureStamp}); err != nil { + t.Fatalf("the composition setup wrote does not build: %v", err) + } + pages := htmlPages(t, out) + for _, want := range []string{ + "index.html", "record/index.html", "record/adr/adr-1/index.html", "record/graph/index.html", + "record/glossary/index.html", "record/health/index.html", + } { + if _, ok := pages[want]; !ok { + t.Errorf("the managed repository's site lacks %s", want) + } + } + if !strings.Contains(pages["record/index.html"], `class="panel tl"`) { + t.Error("the managed repository's dashboard lacks the timeline") + } + if !strings.Contains(pages["index.html"], "The example repository") { + t.Error("the landing page is not composed from the repository's own text") + } +} + +// --- criterion 5: re-runnable ------------------------------------------------------ + +func TestASecondRunWritesNothingAndSaysSo(t *testing.T) { + h := newHarness(t).withCredential() + h.run(t, withDomain("docs.example.com")) + forgeWrites, hostWrites := len(h.forge.writes), h.host.Writes() + + res := h.run(t) + if res.Status != StatusNoChange { + t.Fatalf("second run status = %q, want %q", res.Status, StatusNoChange) + } + for _, f := range res.Files { + if f.Status != "current" && f.Status != "kept" { + t.Errorf("second run: %s is %q", f.Path, f.Status) + } + } + if len(h.forge.writes) != forgeWrites { + t.Errorf("second run wrote to the forge: %v", h.forge.writes[forgeWrites:]) + } + if h.host.Writes() != hostWrites { + t.Errorf("second run wrote to the host: %v", h.host.CallLog()) + } + if res.Host.Status != HostCurrent || res.Host.Address != "https://docs.example.com" { + t.Errorf("second run host = %+v", res.Host) + } +} + +// --- the confirmation and the failures ------------------------------------------- + +func TestADeclinedRunWritesNothingRemote(t *testing.T) { + h := newHarness(t).withCredential() + h.ask = answer(false) + res := h.run(t) + if len(h.forge.writes) != 0 || h.host.Writes() != 0 { + t.Fatalf("a declined run wrote remotely: forge %v host %v", h.forge.writes, h.host.CallLog()) + } + if res.Status != StatusDeclined || res.Host.Status != HostDeclined { + t.Fatalf("status %q host %q, want declined", res.Status, res.Host.Status) + } + for _, e := range res.Environments { + if e.Status != RemoteDeclined { + t.Errorf("environment %s is %q, want declined", e.Name, e.Status) + } + } +} + +// TestAnExistingEnvironmentIsNeverRewritten: the forge's environment write +// replaces the environment's whole protection set, so an environment that +// already exists is never written through it. One that admits more than the +// default branch and release tags is left as it is and named as a remaining +// step, ahead of any secret step — whether it admits more through its +// protection mode or through a custom rule of its own; one already on custom +// policies and nothing broader only gains the policies it lacks. An absent +// environment is still created. +func TestAnExistingEnvironmentIsNeverRewritten(t *testing.T) { + cases := []struct { + name string + state EnvironmentState + policies []BranchPolicy + names string + }{ + {"admits every ref", EnvironmentState{}, nil, ""}, + {"admits protected branches", EnvironmentState{ProtectedBranches: true}, nil, ""}, + {"admits every branch through a custom rule", EnvironmentState{CustomBranchPolicies: true}, + []BranchPolicy{{Name: "main", Type: "branch"}, {Name: "*", Type: "branch"}}, "branch *"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t) + h.forge.envs[EnvDeploy] = c.state + h.forge.policies[EnvDeploy] = c.policies + h.forge.protection[EnvDeploy] = []string{"required reviewers: example-reviewer"} + res := h.run(t) + + for _, w := range h.forge.writes { + if strings.HasSuffix(w, " "+EnvDeploy) || strings.Contains(w, " "+EnvDeploy+" ") { + t.Errorf("the existing environment %s was written: %q (all writes %v)", EnvDeploy, w, h.forge.writes) + } + } + if got := h.forge.protection[EnvDeploy]; len(got) != 1 { + t.Errorf("the existing environment's reviewers were lost: %v", got) + } + if !reflect.DeepEqual(h.forge.writes, []string{ + "put " + EnvRender, "policy " + EnvRender + " branch main", "policy " + EnvRender + " tag v*", + }) { + t.Errorf("forge writes = %v, want only the absent %s created", h.forge.writes, EnvRender) + } + var deploy EnvironmentOutcome + for _, e := range res.Environments { + if e.Name == EnvDeploy { + deploy = e + } + } + if deploy.Status != RemoteUnrestricted { + t.Errorf("%s status = %q, want %q", EnvDeploy, deploy.Status, RemoteUnrestricted) + } + restrict, secret := -1, -1 + for i, r := range res.Remaining { + if restrict < 0 && strings.Contains(r, "restrict the existing environment "+EnvDeploy) && + strings.Contains(r, "branch main and tags v*") { + restrict = i + } + if secret < 0 && strings.Contains(r, "gh secret set") { + secret = i + } + } + if restrict < 0 || secret < 0 || restrict > secret { + t.Errorf("the restriction step is missing or follows a secret step (restrict %d, secret %d):\n%s", + restrict, secret, strings.Join(res.Remaining, "\n")) + } else if c.names != "" && !strings.Contains(res.Remaining[restrict], c.names) { + t.Errorf("the restriction step does not name the rule %q it has to remove:\n%s", c.names, res.Remaining[restrict]) + } + }) + } + t.Run("already on custom policies", func(t *testing.T) { + h := newHarness(t) + h.forge.envs[EnvDeploy] = EnvironmentState{CustomBranchPolicies: true} + h.forge.policies[EnvDeploy] = []BranchPolicy{{Name: "main", Type: "branch"}} + h.forge.protection[EnvDeploy] = []string{"required reviewers: example-reviewer"} + h.run(t) + for _, w := range h.forge.writes { + if w == "put "+EnvDeploy { + t.Errorf("the existing environment %s was rewritten (writes %v)", EnvDeploy, h.forge.writes) + } + } + if !reflect.DeepEqual(h.forge.policies[EnvDeploy], []BranchPolicy{{Name: "main", Type: "branch"}, {Name: "v*", Type: "tag"}}) { + t.Errorf("%s policies = %v, want the missing tag policy added", EnvDeploy, h.forge.policies[EnvDeploy]) + } + if len(h.forge.protection[EnvDeploy]) != 1 { + t.Errorf("the existing environment's reviewers were lost: %v", h.forge.protection[EnvDeploy]) + } + }) +} + +func TestEveryForgeFailureIsReportedAndStopsTheRemoteWrites(t *testing.T) { + for _, call := range []string{"Environments", "Policies", "PutEnvironment", "AddPolicy"} { + t.Run(call, func(t *testing.T) { + h := newHarness(t).withCredential() + if call == "Policies" { + h.forge.envs[EnvRender] = EnvironmentState{CustomBranchPolicies: true} + } + h.forge.fail[call] = errors.New("forge said no") + res := h.run(t) + if res.Status != StatusRefused { + t.Fatalf("status = %q, want refused", res.Status) + } + if !strings.Contains(strings.Join(res.Notes, "\n"), "forge said no") { + t.Fatalf("the failure is not in the notes: %v", res.Notes) + } + if h.host.Writes() != 0 { + t.Fatalf("the host was written after the forge failed: %v", h.host.CallLog()) + } + // The secret steps are still printed, so the step that protects + // the environment they land in comes first: without it the + // workflow's first run creates site with no deployment policy. + create, secret := -1, -1 + for i, r := range res.Remaining { + if create < 0 && strings.Contains(r, "create the forge environments "+EnvRender+" and "+EnvDeploy) { + create = i + } + if secret < 0 && strings.Contains(r, "gh secret set") { + secret = i + } + } + if create < 0 || secret < 0 || create > secret { + t.Fatalf("a refused forge stage must name the environment step before any secret step (create %d, secret %d):\n%s", + create, secret, strings.Join(res.Remaining, "\n")) + } + }) + } + t.Run("SecretNames", func(t *testing.T) { + h := newHarness(t) + h.forge.fail["SecretNames"] = errors.New("forge said no") + res := h.run(t) + if !strings.Contains(strings.Join(res.Remaining, "\n"), "gh secret set CLOUDFLARE_API_TOKEN") { + t.Fatalf("an unreadable secret list must still name the secret step: %v", res.Remaining) + } + if !strings.Contains(strings.Join(res.Notes, "\n"), "forge said no") { + t.Fatalf("the failure is not in the notes: %v", res.Notes) + } + }) +} + +func TestEveryProviderFailureIsReported(t *testing.T) { + for _, route := range []string{ + cloudflaretest.ListAccounts, cloudflaretest.ListWorkers, cloudflaretest.ListDomains, + cloudflaretest.CreateWorker, cloudflaretest.AttachDomain, + } { + t.Run(route, func(t *testing.T) { + h := newHarness(t).withCredential() + h.host.Fail[route] = 500 + res := h.run(t, withDomain("docs.example.com")) + if res.Status != StatusRefused || res.Host.Status != HostRefused { + t.Fatalf("status %q host %q, want refused", res.Status, res.Host.Status) + } + if !strings.Contains(res.Host.Detail, "500") { + t.Fatalf("the host's failure is not reported: %q", res.Host.Detail) + } + if strings.Contains(res.Host.Detail, cloudflaretest.Token) { + t.Fatal("the failure carries the credential") + } + }) + } + t.Run(cloudflaretest.GetSubdomain, func(t *testing.T) { + h := newHarness(t).withCredential() + h.host.Fail[cloudflaretest.GetSubdomain] = 500 + res := h.run(t) + if res.Status != StatusRefused || !strings.Contains(res.Host.Detail, "500") { + t.Fatalf("status %q host %+v, want the address failure refused", res.Status, res.Host) + } + }) +} + +func TestAnUnreadableCredentialIsRefusedNotSkipped(t *testing.T) { + h := newHarness(t) + h.cred = fixedCredential{err: errors.New("credential: the store is readable by others")} + res := h.run(t) + if res.Host.Status != HostRefused || res.Status != StatusRefused { + t.Fatalf("status %q host %q, want refused", res.Status, res.Host.Status) + } + if len(h.host.CallLog()) != 0 { + t.Fatalf("the provider was called without a credential: %v", h.host.CallLog()) + } +} + +func TestSetupRefusesAFolderAbcdDoesNotManage(t *testing.T) { + h := newHarness(t) + h.repo.Write("AGENTS.md", "# Example\n") + _, err := Setup(SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()}) + if !errors.Is(err, ErrNotManaged) { + t.Fatalf("err = %v, want ErrNotManaged", err) + } +} + +func TestDriftedMachineryRefusesTheWholeRun(t *testing.T) { + h := newHarness(t).withCredential() + h.repo.Write(SiteWorkflowRelPath, "name: site\n# hand-made\n") + res := h.run(t) + if res.Status != StatusRefused { + t.Fatalf("status = %q, want refused", res.Status) + } + if _, err := os.Stat(filepath.Join(h.repo.Root(), ManifestRelPath)); !os.IsNotExist(err) { + t.Fatal("a refused run wrote the composition") + } + if len(h.forge.writes) != 0 || len(h.host.CallLog()) != 0 { + t.Fatalf("a refused run reached the forge %v or the host %v", h.forge.writes, h.host.CallLog()) + } + res = h.run(t, func(r *SetupRequest) { r.Confirm = true }) + if fileStatuses(res)[SiteWorkflowRelPath] != "written" { + t.Fatalf("--confirm did not replace the drifted workflow: %v", res.Files) + } +} + +func TestSetupRefusesWithoutAnIdentityOrAPage(t *testing.T) { + h := newHarness(t) + h.repo.Remove(".abcd/positioning.json") + if _, err := Setup(SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()}); err == nil || + !strings.Contains(err.Error(), "abcd identity init") { + t.Fatalf("no identity block: err = %v, want one naming `abcd identity init`", err) + } + h = newHarness(t) + h.repo.Remove("docs/README.md") + if _, err := Setup(SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()}); err == nil || + !strings.Contains(err.Error(), "docs/README.md") { + t.Fatalf("no page: err = %v, want one naming docs/README.md", err) + } +} + +func TestAnUnsafeNameOrDomainIsRefused(t *testing.T) { + h := newHarness(t) + for _, m := range []func(*SetupRequest){ + func(r *SetupRequest) { r.Name = "Not_A_Name" }, + func(r *SetupRequest) { r.Domain = "https://example.com/x" }, + } { + req := SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()} + m(&req) + if _, err := Setup(req); err == nil { + t.Fatalf("setup accepted %+v", req) + } + } +} + +// --- the workflow ------------------------------------------------------------------ + +// TestTheWorkflowInterpolatesNothingIntoAShell: a `${{ … }}` expression inside a +// run block is spliced into the script before the shell parses it, which is +// the injection shape; every value reaches the shell through env instead. +func TestTheWorkflowInterpolatesNothingIntoAShell(t *testing.T) { + wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}) + if err != nil { + t.Fatal(err) + } + inRun, runIndent := false, 0 + for i, line := range strings.Split(string(wf), "\n") { + indent := len(line) - len(strings.TrimLeft(line, " ")) + trimmed := strings.TrimSpace(line) + if inRun && trimmed != "" && indent <= runIndent { + inRun = false + } + if strings.HasPrefix(trimmed, "run:") || strings.HasPrefix(trimmed, "- run:") { + if strings.Contains(trimmed, "${{") { + t.Errorf("line %d interpolates into a shell: %s", i+1, trimmed) + } + inRun, runIndent = true, indent + continue + } + if inRun && strings.Contains(line, "${{") { + t.Errorf("line %d interpolates into a shell: %s", i+1, trimmed) + } + } + for _, want := range []string{"environment: site-render", "environment: site\n", "persist-credentials: false"} { + if !strings.Contains(string(wf), want) { + t.Errorf("the workflow lacks %q", want) + } + } +} + +// TestTheWorkflowPinsFollowAbcdsOwn: every action the setup workflow uses is +// pinned to the commit abcd's own site workflow uses, so a pin bump there is a +// failing test here rather than a quietly stale copy in every managed repo. +func TestTheWorkflowPinsFollowAbcdsOwn(t *testing.T) { + own, err := os.ReadFile(filepath.Join("..", "..", "..", ".github", "workflows", "site.yml")) + if err != nil { + t.Fatal(err) + } + wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}) + if err != nil { + t.Fatal(err) + } + usesRe := regexp.MustCompile(`uses: ([^@\s]+)@([0-9a-f]{40})`) + pins := map[string]string{} + for _, m := range usesRe.FindAllStringSubmatch(string(own), -1) { + pins[m[1]] = m[2] + } + found := usesRe.FindAllStringSubmatch(string(wf), -1) + if len(found) == 0 { + t.Fatal("the setup workflow pins no action") + } + for _, m := range found { + if pins[m[1]] != m[2] { + t.Errorf("%s is pinned to %s here and %q in abcd's own site workflow", m[1], m[2], pins[m[1]]) + } + } + if v := regexp.MustCompile(`wranglerVersion: '([^']+)'`).FindStringSubmatch(string(own)); v == nil || + !strings.Contains(string(wf), "wranglerVersion: '"+v[1]+"'") { + t.Errorf("the wrangler version differs from abcd's own site workflow") + } +} + +// TestTheSeedSourcesAreAbcdsOwn: the static inputs setup seeds a managed +// repository with are byte-identical to the ones abcd's own site renders from. +func TestTheSeedSourcesAreAbcdsOwn(t *testing.T) { + for _, name := range seedNames { + own, err := os.ReadFile(filepath.Join("..", "..", "..", "site-src", name)) + if err != nil { + t.Fatal(err) + } + seed, err := setupSources.ReadFile("setupsrc/" + name) + if err != nil { + t.Fatal(err) + } + if string(own) != string(seed) { + t.Errorf("setupsrc/%s differs from site-src/%s; copy it across", name, name) + } + } +} + +// TestAWorkflowRunFromAForkNeverRenders: the workflow_run entry admits only a +// successful run of this repository's own release workflow, never a pull +// request's and never a fork's. +func TestAWorkflowRunFromAForkNeverRenders(t *testing.T) { + wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "github.event.workflow_run.conclusion == 'success'", + "github.event.workflow_run.event != 'pull_request'", + "github.event.workflow_run.head_repository.full_name == github.repository", + } { + if !strings.Contains(string(wf), want) { + t.Errorf("the render job's gate lacks %q", want) + } + } +} + +// TestTheWorkflowFiresOnEveryReleasePath: on a repository `launch scaffold` +// laid out, auto-release runs release.yml by workflow_call, so no run named +// `release` exists for it; a hand-pushed tag's `release` run has the TAG as its +// head branch; and a release made with the workflow's own token fires no +// `release: published`. So the workflow_run entry names both release +// workflows, filters no branch in the trigger (a branch filter drops the tag), +// and admits the default branch or a v-tag inside the render job's gate. +func TestTheWorkflowFiresOnEveryReleasePath(t *testing.T) { + wf, err := renderSiteWorkflow("trunk", cloudflare.Adapter{}) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(wf), "\n") + block := func(start string, end func(string) bool) string { + var out []string + in := false + for _, l := range lines { + if !in { + in = strings.TrimRight(l, " ") == start + continue + } + if end(l) { + break + } + out = append(out, l) + } + if !in { + t.Fatalf("the workflow has no %q", start) + } + return strings.Join(out, "\n") + } + on := block("on:", func(l string) bool { return l != "" && l[0] != ' ' && l[0] != '#' }) + wantNames := []string{"release", "auto-release"} + if !strings.Contains(on, " workflows: ["+strings.Join(wantNames, ", ")+"]") { + t.Errorf("the workflow_run entry does not name %v:\n%s", wantNames, on) + } + if strings.Contains(on, "branches:") { + t.Errorf("the trigger filters branches, which drops a tag-push release run:\n%s", on) + } + for _, name := range wantNames { + src, err := os.ReadFile(filepath.Join("..", "launch", "scaffold", "templates", name+".yml.tmpl")) + if err != nil { + t.Fatal(err) + } + if first, _, _ := strings.Cut(string(src), "\n"); first != "name: "+name { + t.Errorf("the scaffolded %s workflow is named %q, not the %q the site workflow listens for", name, first, name) + } + } + gate := block(" if: >-", func(l string) bool { return strings.HasPrefix(l, " runs-on:") }) + for _, want := range []string{ + "github.event.workflow_run.head_branch == 'trunk'", + "startsWith(github.event.workflow_run.head_branch, 'v')", + "github.event.workflow_run.head_repository.full_name == github.repository", + } { + if !strings.Contains(gate, want) { + t.Errorf("the render job's gate lacks %q:\n%s", want, gate) + } + } +} diff --git a/internal/core/site/setupsrc/headers b/internal/core/site/setupsrc/headers new file mode 100644 index 000000000..e78179290 --- /dev/null +++ b/internal/core/site/setupsrc/headers @@ -0,0 +1,133 @@ +# Source of the Cloudflare _headers file. `abcd site build` copies it into the +# output tree as `_headers`, comments and all. +# +# Every file the build emits must match a block here. +# `TestEveryRouteHasASecurityHeaderBlock` keeps it two ways: it walks a whole +# emitted tree and fails on any file matching no block, and it holds THIS file to +# the route families the committed build emits, with the asset routes read off +# the build's own copy list rather than restated — so a new copied source cannot +# ship unprotected by being forgotten here. +# +# What a block must carry depends on what the host serves it as: +# +# a DOCUMENT a content policy, `nosniff` and a referrer policy. +# an ASSET `nosniff` and a referrer policy. A content policy governs what +# a document may LOAD, and a stylesheet, a script file, an image +# or a JSON export loads nothing — so it would be a directive +# with no subject, not a tighter lock. +# +# Three emitted files are not routes and have no block. `_headers` and +# `_redirects` are the host's own configuration: it reads them and serves +# neither. `.abcd-site-build` is the build's own marker — it is what lets a +# rebuild clear this directory instead of refusing it, and it describes nothing +# but itself. The walk excludes those three by name rather than by a pattern, so +# a new file cannot slip past the check by resembling one of them. +# +# /install.sh is read before it is run, so it is served as text, never as a +# download. +/install.sh + Content-Type: text/plain; charset=utf-8 + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + +# The landing page's content policy. The build already refuses a script or an +# event handler inside a committed SVG and refuses an executable link scheme, so +# this is the second lock, not the first: if a way past those is ever found, the +# injected code still has no origin to run in. +# +# It is scoped to the generated pages rather than the whole site because /docs/ +# is rendered by a different generator whose own inline scripts this would break. +# +# What the page legitimately does: load its own stylesheet and script, the +# Google Fonts stylesheet and the font files it points at, and its own copied +# rasters. It fetches nothing, embeds nothing, and submits nothing — so +# connect-src, frame-src and form-action are closed, and default-src 'none' +# closes everything not named. style-src-attr allows the layout custom +# properties the build writes as style="--stack:22px". +/ + Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'; object-src 'none' + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + +# The record explorer. One policy for the whole family, directive by directive: +# +# default-src 'none' nothing not named below may load at all. +# script-src 'self' site.js on every route; record.js on /record/graph/. +# No 'unsafe-inline' and no 'unsafe-eval': every script +# the explorer runs is a file this build wrote. +# style-src 'self' … site.css, plus the Google Fonts stylesheet. +# style-src-attr the build writes per-datum style attributes that no +# 'unsafe-inline' stylesheet can carry: a lifecycle bar's segment +# widths (style="width:42.86%") and an assistance bar's +# length are numbers derived per record. The narrowest +# directive that permits them is this one — it governs +# ATTRIBUTES only, so a