diff --git a/.abcd/config.json b/.abcd/config.json index ce3d6c539..57c12bdce 100644 --- a/.abcd/config.json +++ b/.abcd/config.json @@ -20,5 +20,8 @@ }, "repo": { "visibility": "public" + }, + "sessions": { + "live": "local" } } diff --git a/.abcd/development/brief/02-constraints/03-invariants.md b/.abcd/development/brief/02-constraints/03-invariants.md index 60c19439e..a71c3a9c6 100644 --- a/.abcd/development/brief/02-constraints/03-invariants.md +++ b/.abcd/development/brief/02-constraints/03-invariants.md @@ -126,7 +126,13 @@ The following are non-negotiable invariants — any architectural choice that vi which refines adr-38 without superseding it). One package alone opens a listener, held by `TestOnlyTheDashboardOpensAListener` (`internal/surface/dashboard`); a later listener is admitted only by - amending that record in the open. + amending that record in the open. Its amendment of 2026-10-14 admits one + second listener, the brief session's relay (`internal/surface/session`). + The relay is started on purpose and listens on loopback unless an address + is chosen on that run. On a person's machine it is reached through + Tailscale Serve, never Funnel. It admits people by the host's join code + and by GitHub's own answer on a presented token, and it holds no project + content at rest. 8. **Acceptance discipline applies uniformly** — every intent's press release is followed by a `## Acceptance Criteria` block in Given-When-Then format (per itd-1). Every brief phase has an `## Acceptance` block in the same format. The format is uniform across the boundary; the *home* differs to match the nature of the work. See [`01-product/03-mental-model.md`](../01-product/03-mental-model.md). diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 99f2f934c..c485a8398 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -1355,6 +1355,7 @@ Sub-verbs: none. | `--dev` | bool | | `--docs-target` | string | | `--install-tool` | stringSlice | +| `--live-sessions` | string | | `--only-waiting` | bool | | `--oracle-backend` | string | | `--outbound` | bool | diff --git a/.abcd/development/brief/04-surfaces/16-lint.md b/.abcd/development/brief/04-surfaces/16-lint.md index e4092a69e..2c8b0accf 100644 --- a/.abcd/development/brief/04-surfaces/16-lint.md +++ b/.abcd/development/brief/04-surfaces/16-lint.md @@ -158,7 +158,7 @@ the identity target runs in no gate. | `decision-durability` | warn | a committed `.abcd/work/decisions/` folder, one file per decision; a repository keeping only a frozen single `DECISIONS.md` log is warned that a new decision has no folder to go to, naming the install that lays one, and its log is never asked to change; decisions not living only in the gitignored layer | | `docs-currency` | warn | reuses the docs-lint engine where `docs/` exists or `.abcd/docs-lint.json` does (its roots need not include `docs/`), and says so where it cannot: a repo with a `docs/` tree but no docs-lint configuration raises a warn against `.abcd/docs-lint.json`, and the docs target's refusal (a configuration that will not load, or one the engine refuses, such as a `roots` entry that does not exist) is an `error` finding against it, rather than passing quietly | | `privacy-hygiene` | error (network-identifier findings mapped from a scanner `warn`/`info` land as `warn`, and so do the two not-scanned findings: a file over the cap, and one that could not be opened) | three leak classes on any tracked text line: absolute local paths in committed files, real network identifiers, and the harness-leak pair the outbound policy bans everywhere (a live agent-session URL, and a tool's own "generated with" footer). The fix names reserved documentation values (RFC 5737/3849/2606/7042, or a persona-derived device name), and an `abcd-lint:allow` line waiver is honoured (the `abcd-audit:allow` spelling too). Each line is read as written and in the scanner's decoded spellings of it (`scanner.DecodedViews`: its percent and JSON-escape views), so a home path, an address or a harness-leak shape written behind an escape in a JSON fixture, export or transcript is the finding its plain spelling is; the waiver is read on the line as written, and the record/docs `harness_leak` rule reads the same spellings. The network severities come from the merged scanner configuration, so a repo that raises one in `.abcd/config/pii.json` is honoured, and an override that cannot be read is itself an `error` finding saying the scan fell back to the built-in severities. Two findings report what was *not* read rather than a leak: a tracked text file over the 4 MiB scan cap, and one that could not be opened. Binary files are skipped silently. In a repository that armed gitleaks (`.abcd/config/gitleaks.json`), each line of a tracked text file gitleaks flags is an `error` finding naming the rule, never the value; an armed gitleaks with no binary installed, or a run that fails, is an `error` finding citing that config | -| `site-gates` | warn | where `.abcd/site.json` declares a site: renders it into a fresh temporary directory outside the repository, runs the website's gates over it (the site target's, [`22-site.md`](22-site.md)), and removes it, so the lint still writes nothing in the repository. Each gate failure is one finding, filed against the source span it names; a composition that cannot be rendered is the site target's refusal, an `error` finding against `.abcd/site.json` rather than an aborted lint. Gate failures are warn, as `docs-currency` is, because the authoritative gate is the site target's exit 1 and re-raising it as an error would double-gate one check | +| `site-gates` | warn | where `.abcd/site.json` declares a site: renders it into a fresh temporary directory outside the repository, runs the website's gates over it (the site target's, [`22-site.md`](22-site.md)), and removes it, so the lint still writes nothing in the repository. Each gate failure is one finding, filed against the source span it names; a project site (`.abcd/site.json` in project mode) is gated on its own route; a composition that cannot be rendered is the site target's refusal, an `error` finding against `.abcd/site.json` rather than an aborted lint. Gate failures are warn, as `docs-currency` is, because the authoritative gate is the site target's exit 1 and re-raising it as an error would double-gate one check | | `identity-positioning` | warn | every registered surface still carries the canonical identity block's tagline (and pitch, where required), and every registered surface can still be found: a surface whose locator matches nothing is its own finding, because drift there would go unseen. A registry or identity block that cannot be read is the identity target's refusal, an `error` finding whatever the family's severity, rather than a pass. Gated on `.abcd/positioning.json` being present on disk, and per-repo upgradeable to `error` (see [`19-identity.md`](19-identity.md)) | ## How it is built @@ -229,6 +229,8 @@ Sub-verbs: none. | Flag | Type | |---|---| +| `--base-path` | string | | `--out` | string | +| `--project` | bool | diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index 5254292de..6f16838f9 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -95,9 +95,63 @@ 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 through the credential store and never written into the repository or the report, which names only the credential's name. A second run over an unchanged repository and host -writes nothing and says so. One provider ships, behind an adapter seam +writes nothing and says so. Two providers ship, behind an adapter seam ([`05-internals/02-adapters.md`](../05-internals/02-adapters.md#hosting-providers)). +## A project's own site + +The second provider publishes a managed repository's site on the project's own +GitHub Pages, built by the project's own workflow at each release +(itd-2610091010348892). Such a site renders only what abcd already writes to the +repository, as its single source, as the product thinker ruled on 2026-10-15. A +project's hand-written documentation is read on GitHub, and nothing is written +for the website. + +So setting one up writes two files and no more: the site's settings, holding +the publish switch and no composition, and the workflow. The interface words, +styles, scripts and headers policy ship in the binary, and the workflow builds +and gates with the project flag, so the repository carries no site sources. +The landing page is an about page derived in code, from four parts in this +order, each rendered only where the repository holds it: the identity block, +the brief's opening (the first section of the brief skeleton, +`01-product/01-press-release.md`), the press releases of the newest shipped +intents whose audit was met, newest id first (a timestamped id carries when +abcd minted it, and an ordinal one sorts before every timestamped one), and the +release list in `CHANGELOG.md`. Without an +identity block the page is headed and titled by the repository's name on +GitHub. A repository holding none of the four is refused at setup and at build, +since its site would be empty. The repository's address and name come from the +origin remote alone, never from the package manifest, which is a person's file. +The record pages are built exactly as for any site, from the record's lint +configuration or, where a managed repository carries none, from abcd's standard +layout under `.abcd/development/`. The issue ledger is always read, so an +intent's typed link to an issue resolves, and an issue is published only where +the settings opt in with `"record": {"issue_ledger": true}`. The references +page and every picture are left out, because each comes from a person's own +files rather than from what abcd writes (pictures are a later intent, +iss-2610150529485165). + +A project site shows nothing read from git history either, and its build does +not read it. Who wrote a commit, what model it declared, how many commits there +are and when any of them landed are git's record, not abcd's, so the +contributors page, the timeline, the health page's authorship checks and +record.json's authorship, history and per-record git dates are all left out, +and a record is dated by its own frontmatter alone. Whether a project site +should show any of it is to be revisited. + +The project route depends on abcd's own releases: the workflow downloads abcd's +latest release to build with, so a project site cannot build until a release of +abcd carries the project flag. Setup states this in a note on every project +setup, since it asks the network nothing to learn which release is latest. + +The gate holds the line: on a project site, a rendered span whose source file +abcd does not write fails the site's gate, on every page, the record's own +included, and so does a site with no title. +Files under `.abcd/` (but the local tier) and `CHANGELOG.md` are abcd's; `docs/`, +`README.md` and every other file are not. The build and the gate name the route +explicitly, and refuse settings of the other shape. abcd's own composed site is +not a project site: it renders its documentation and stays as it is. + ## The page set Every repository gets the same pages: the landing page, the record explorer, @@ -310,10 +364,12 @@ Sub-verbs: none. | Flag | Type | |---|---| +| `--base-path` | string | | `--commit` | string | | `--date` | string | | `--out` | string | | `--preview` | bool | +| `--project` | bool | | `--version` | string | ### `abcd site setup` @@ -325,6 +381,8 @@ Sub-verbs: none. | `--confirm` | bool | | `--domain` | string | | `--name` | string | +| `--provider` | string | +| `--replace-existing` | bool | | `--yes` | bool | diff --git a/.abcd/development/brief/04-surfaces/37-session.md b/.abcd/development/brief/04-surfaces/37-session.md new file mode 100644 index 000000000..d0b3afc17 --- /dev/null +++ b/.abcd/development/brief/04-surfaces/37-session.md @@ -0,0 +1,299 @@ +# `/abcd:session` — The Brief Session's Relay + +`/abcd:session` sets up live brief sessions for a project (the team's own +GitHub sign-in app, from a pre-filled manifest), starts a live brief session +on the host's own computer, runs the relay a team's cloud sessions go through, +writes the blueprint that deploys it as the team's own relay, and writes the +static page the session is held on (itd-2610091016262335, spc-2610141709430146 +steps 1 to 6). The page +never shows or asks for a relay address or a token +([adr-2610161400542916](../../decisions/adrs/2610161400542916-the-brief-session-page-asks-nobody-for-a.md)). abcd runs no relay +and holds no key +([adr-2610091016270711](../../decisions/adrs/2610091016270711-who-may-change-an-abcd-managed-project-s.md)): +the team runs it, on the host's own machine or on the team's own Render +account. It is abcd's second listener, admitted on the terms of +[adr-2610032150581128](../../decisions/adrs/2610032150581128-abcd-is-reachable-on-a-network-only.md)'s +amendments of 2026-10-14 and 2026-10-16 and brief invariant 7's inbound clause +([`02-constraints/03-invariants.md`](../02-constraints/03-invariants.md)). + +This chapter describes the relay, its blueprint and the page. The route back +to GitHub is the spec's later step. + +## Sub-verbs + +> _Machine-checked (`surface_coverage`, spc-27): each row records the verb's +> adr-40 bucket (`lint` / `review` / `audit` / `gate`, or `—` for a +> non-assessment verb) and its existence (`shipped` / `staged`). The existence +> fact is verified against the committed command-tree snapshot in both +> directions. The bucket cell is checked for membership of the closed adr-40 +> vocabulary only: the snapshot carries no bucket field, so a bucket that is +> wrong but legal passes, and that cell stays a review-grain claim._ + +| Verb | Bucket | Status | +|---|---|---| +| `setup` | — | shipped | +| `start` | — | shipped | +| `serve` | — | shipped | +| `blueprint` | — | shipped | +| `page` | — | shipped | + +## Where the relay listens + +The relay runs in the foreground until interrupted and listens on loopback +(`127.0.0.1:8787`) unless the host names another address on that run. A +non-loopback address is the second, explicit choice the amendment requires, +and is meant for the team's relay, which listens on every address behind its +host's proxy and trusts that proxy's forwarded address. On a person's own +machine, the local session runs it on loopback, serves the page from it as +well, and +prints the Tailscale Serve command that puts it on the tailnet, with the link +to open. Funnel is never offered: someone outside the tailnet joins once the +host shares the computer with them in Tailscale, as a guest. A local session +answers +only the computer's tailnet name and its loopback address, as hosts (any other +gets 421) and as page origins. + +It serves these routes and nothing else: the session's WebSocket at +`/session`, the web-flow sign-in's callback at `/auth/callback` and its +exchange at `/auth/token`, the device-flow sign-in at `/auth/device`, +`/auth/device/token` and `/auth/device/cancel` (a local session only), the sign-in app's public +client id and flow at `/config`, a health check at `/healthz`, and, for a +local session only, the page's embedded files from a fixed route table. A browser is admitted only from +`https://abcdesign.app`, a loopback page or another origin the host names, each +an exact origin; a WebSocket or exchange with no origin, or another one, is +refused with 403. + +## Who may do what + +A connection is a guest until GitHub says otherwise. The host signs in, and +GitHub must report that the login may push to the repository before a session +opens; the relay answers with a six-character join code drawn from a +cryptographic source over 31 symbols with the look-alike characters left out. +The host can draw a new code at any time, and the old one stops working. A +host whose page reloads keeps the role by signing in as the same login. + +Anyone with the code joins as a guest and receives the session's update log +replayed, then every update as it arrives. A guest's update frame is refused, +which is how a guest stays read-only on the text; a guest's `suggest` message +is routed to the team members alone, stamped with the guest's display name, +cleaned of control and format characters. A participant who presents the +GitHub App user access token their sign-in gave them (`ghu_`; any other kind +is refused before GitHub is asked, so nobody pastes a token) becomes a team +member when GitHub reports push access, and the token is dropped straight +after the check. Team members' +updates are logged and forwarded; awareness (cursors and selections) is +forwarded from anyone and never logged. A team member registers the Yjs client +ids their page edits under, which the roster carries for credit. + +Twenty wrong codes from one address within ten minutes shut that address out +of joining until the window passes; an IPv6 visitor is counted by its /64, and +the forwarded address is trusted only behind a declared proxy. + +## What it holds, and for how long + +Updates are opaque bytes behind a one-byte tag; the relay never parses them or +the brief. Everything is in memory: a session is dropped whole when the host +closes it, or when nobody has been connected for thirty minutes. Nothing is +written to disk and nothing is logged, no token, secret or brief text. The +sign-in app's secret, on a cloud relay, is read from +`ABCD_SESSION_GITHUB_CLIENT_SECRET` by name, added only to the relay's own +exchange with GitHub, and never sent to a browser; GitHub's answer is passed +on field by field, so nothing it echoes reaches the page. A local session's +relay, which serves its own page and holds the client id and no secret (it +never reads one), offers GitHub's device flow instead and keeps each device +code in memory under a random handle, at most 64 at once and four from one +client address, never sending it to a browser. A relay that serves no page +offers the device flow never: without the secret it offers no sign-in, and +the page on any origin but its own relay's signs in through the web flow +alone. + +Limits: 256 KiB a message, 8 MiB of log a session, 32 participants a session, +16 sessions a relay, five sign-in attempts a connection, and a connection that +falls a thousand frames behind or misses a ping is dropped. + +## The blueprint + +The blueprint is `.abcd/relay/render.yaml`, one free Render web service with +the docker runtime, a `/healthz` health check, automatic deploys off (a +redeploy ends every open session) and the sign-in app's two variables +unsynced, and `.abcd/relay/Dockerfile`, which downloads the named release's +`abcd-linux-amd64` and checks it against the SHA-256 that release's +`checksums.txt` publishes, read when the files are written and pinned in the +file, before running the relay on every address behind Render's proxy. Each +file's first line carries a digest of its body, so a +rerun replaces a file left as abcd wrote it and refuses, writing nothing, one +that was edited or that abcd did not write. It refuses outside a checkout abcd +manages and for a release that publishes no checksum for the linux/amd64 +binary. + +## The page + +The page is written into a directory the person names, from files embedded in +the binary: `index.html` and an identical `404.html`, `session.js`, +`session.css`, and the vendored editor bundle `vendor/collab.js` with its +licences +([adr-2610141949110653](../../decisions/adrs/2610141949110653-the-brief-session-s-browser-editor-ships.md)). +It writes those six files and nothing else and removes nothing. The directory +may be absent, empty, or one an earlier run wrote, whose files are replaced; +one holding anything else, or the page's names without the `index.html` it +wrote, is refused with nothing written. The bundle's bytes and its entry +module are checked against the committed provenance by a test. + +A static host's 404 fallback serves the page at `///brief/`; the +HTML names its own files three levels up, so a base path works too. Its +content-security policy loads scripts and styles from the page's own origin +only and lets it connect over https and wss, or to loopback for a local +relay; it sends no referrer, and every piece of brief, relay or participant +text is put on the page as text, never as markup. + +- **Finding the relay.** Nobody types a relay address. A page a local session + serves takes its own origin. On a static host the page uses the relay the invite + link's fragment names, if the repository's `.abcd/relay/relays.json` names + it, or else the first relay the file names; where the file cannot be read + before sign-in, it asks in plain words whether the project's team sent the + link; that answer holds in that tab, for at most twelve hours. With none, it says live sessions are not set up yet and to ask the + technical facilitator. The invite link carries the relay only when the + guest's page could not find it alone. +- **Signing in.** "Sign in with GitHub" goes through a relay the repository's + file names, a confirmed one where the file cannot be read, or a local + session's own page. On a cloud relay it + goes to GitHub's authorize page with PKCE and a random state, both held in + session storage across the redirect, and with the relay's own + `/auth/callback` as the redirect, the callback URL the team's app lists. + GitHub returns the person to that relay, which sends them back to the page + naming itself, only to a session page under the base path the page's + origin entry names (the root unless the entry names one), and the page posts the code and verifier to that relay's + `/auth/token` alone, refusing a return that names no relay or another one; + the relay exchanges only a code given for its own callback, and the token + is presented only to that relay. In a local session, the page shows the device flow's code + to confirm on github.com and asks the relay after it. There is no token + field. The token is held in the page's memory only, and is presented to the + relay as `verify`. +- **Hosting.** A team member hosts; the page reads the default branch's brief + through GitHub's API with the host's token and seeds one shared document + with a text per file, once, recording the base commit, the branch, the + route back (one pull request by default, or one issue per change) and a + snapshot of the seeded state. The seed is sent before the invite shows: a + QR code of the page's address with the relay and code in the fragment, the + code in large letters, and buttons for a new code and to end the session. +- **Editing.** The session opens on the brief's press release. A list of + the brief's documents, grouped by folder, and a Markdown editor bound to + the shared text, with each participant's cursor and name. A team member's + edits are sent as they are typed and the page registers its client id for + credit; edits made while the connection is down are sent when it returns, + and the page rejoins with the same code and replays the log. +- **Guests.** A guest's editor is read-only. A guest selects a passage and + proposes a replacement, sent with the passage's relative positions and a + text quote of it. Team members see it in a suggestions panel: Apply replaces + the passage, found by its positions or else by its quote and refused when + neither still matches, and records the guest, the approving member, the text + before and after, and the time, in one change; Decline drops it, and both + are recorded so every member's panel agrees. +- **Sending.** A team member opens Send to GitHub, which lists each changed + passage with its credit, the text before and after, and the route the host + chose: one pull request for the session, or a pre-filled issue per passage. +- **Two designs.** The page has two looks over the same elements and the same + logic, which the product thinker chose on 2026-10-16 so the team can try + them against each other. Guided, the default, has large cards, numbered + areas, a "What happens next" note (before the session on each step the + page can go on from and when sessions are not set up, never while it is + checking or cannot go on; live, for guests only), and arrival cards whose + main buttons share a line, on one control height per size and a 4 px spacing scale. + Shared puts a live team bar over the document, with an icon-only Invite at + its end, an icon-only Send to GitHub that widens to its label on hover or + keyboard focus, and Suggestions alone on the right, mirroring Documents on + the left; as the window narrows, labels fade and fold to their icons, least + important first, never cut, wrapped or overlapping; it has no "What happens + next", and every overlay has a Close X at its top right. Ctrl+Shift+[ and + Ctrl+Shift+] (Ctrl+{ and Ctrl+}) switch between the designs on every + platform, the Mac included (never Cmd), leaving Ctrl+[ and Ctrl+] to the + editor's indent and outdent, with a short toast naming the design. On + Windows and Linux those keys are the editor's fold and unfold, which the + page takes; it shows no fold arrows, so a section cannot be folded there + (a Mac folds with Cmd+Alt+[ and Cmd+Alt+]); the choice is kept per viewer in the + browser's local storage, beside the display name, and an unreadable or + unknown value falls back to Guided. In both, Alt+Shift+D shows or hides the + documents and Alt+Shift+S the right panel, as their tooltips say, each + toggle keeping one name and showing whether it is on; a hidden sidebar + gives its room to the brief, and the state lasts through every change of + screen and design. A phone, where the sidebars become sheets, keeps its + own state apart from a wider window's, and so does the right panel at the + slide-over widths, so closing it there never hides it on a wide window. + Escape closes the overlay on top that can be seen; inside the editor, and + while an input method is composing, Escape is theirs. A dialog takes focus + when it opens and gives it back to what opened it, and focus on a control + a change of layout hides moves to the control that does its job now. Both follow the light or dark setting and + reduced motion, reach every control from the keyboard with a visible focus, + and use the system's own font, inline SVG icons and one cobalt accent token + (#2457d6, or #7aa2ff in dark). + +## Exit codes + +`0` done; `1` a failure after the relay started, or while the page was being +written; `2` refused, with nothing +started or written. + + + +## Appendix: the shipped surface + +_Generated from the command tree; a drift test fails `go test` when this appendix and the tree disagree. It lists flags and sub-verbs only. What each flag means is in the [CLI reference](../../../../docs/reference/cli/commands.md), and exit codes, output fields and behaviour are the prose's to state._ + +### `abcd session` + +Sub-verbs: `abcd session blueprint`, `abcd session page`, `abcd session serve`, `abcd session setup`, `abcd session start`. + +Flags: none. + +### `abcd session blueprint` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--origin` | stringArray | +| `--relay-origin` | string | +| `--release` | string | + +### `abcd session page` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--base-path` | string | +| `--out` | string | + +### `abcd session serve` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--behind-proxy` | bool | +| `--client-id` | string | +| `--listen` | string | +| `--origin` | stringArray | +| `--owner` | stringArray | +| `--repo` | stringArray | + +### `abcd session setup` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--relay-origin` | string | +| `--release` | string | +| `--where` | string | + +### `abcd session start` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--client-id` | string | +| `--port` | int | + + diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 7ae7677db..ce71cd54c 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -47,6 +47,7 @@ are wiring rather than user-facing surface are listed separately under | 37 | `/abcd:embargo` | shipped | Keep a privately reported security advisory off the public ledger until its fix ships, then close or publish it on your approval of exactly what it shows | [`37-embargo.md`](37-embargo.md) | | 37 | `/abcd:memory` | shipped | Keep short lessons an agent would otherwise relearn, stamped with who wrote them, when and in which session, and refused when they would hold a secret, a private name or a network address | [`37-memory.md`](37-memory.md) | | 37 | `/abcd:reserve` | shipped | Reserve an issue, intent or release on the shared remote before starting it, so another person, computer or hosted session sees it is taken | [`37-reserve.md`](37-reserve.md) | +| 37 | `/abcd:session` | shipped | Run the relay a team's live brief session goes through, on the host's machine over Tailscale Serve or on the team's own relay, write that relay's blueprint, and write the session's static page | [`37-session.md`](37-session.md) | | 38 | `/abcd:announce` | shipped | Post a short fixed-form announcement on the shared remote, so every other session, under any account, on any machine or in the cloud, sees what this one is doing | [`38-announce.md`](38-announce.md) | | 38 | `/abcd:land` | shipped | Push a branch once its preflight passed and queue its pull request where the merge gate allows, resumable by running it again | [`38-land.md`](38-land.md) | | 39 | `/abcd:status` | shipped | Read everyone's status on the shared remote: the announcements every session posted, who holds which reservation and each release being cut | [`39-status.md`](39-status.md) | @@ -144,7 +145,7 @@ the command's sentence (the section below). | Block | Group | Verbs | |---|---|---| -| people | set-up | `ahoy`, `dashboard`, `update`, and the framework's `help` and `completion` | +| people | set-up | `ahoy`, `dashboard`, `session`, `update`, and the framework's `help` and `completion` | | people | records | `build`, `capture`, `decide`, `ideate`, `intent`, `land`, `library`, `status` | | people | checks | `lab`, `reading` | | people | portability | `disembark`, `embark` | @@ -225,8 +226,9 @@ read-only render of their own state, and close on the next move where there is one to name. It is a convention rather than a universal, and the exceptions are where the -tree does not yet meet its own discipline. Eight parents print usage with no state -at all: `disembark`, `docs`, `embargo`, `embark`, `guard`, `history`, `ideate`, and `scribe`. Bare +tree does not yet meet its own discipline. Nine parents print usage with no state +at all: `disembark`, `docs`, `embargo`, `embark`, `guard`, `history`, `ideate`, `scribe`, +and `session`, whose running relay keeps its sessions in that process's memory alone. Bare `abcd launch` refuses with a hint to pass `--dry-run`. Bare `abcd build` refuses as a usage error because its one operand is the intent it starts a run for; the run's state renders through `abcd implement status`. Bare @@ -359,7 +361,7 @@ documents is then an unknown command (iss-161). One file per verb, directly unde `decide`, `disembark`, `docs`, `drain`, `embargo`, `embark`, `guard`, `history`, `ideate`, `identity`, `implement`, `inbox`, `intent`, `lab`, `land`, `launch`, `library`, `lint`, `memory`, `mode`, `peers`, `reading`, `reflect`, `report`, -`reserve`, `scribe`, `site`, `status`, `update`. +`reserve`, `scribe`, `session`, `site`, `status`, `update`. `abcd.md` is the bare `/abcd` status board; every other file is `/abcd:`. diff --git a/.abcd/development/brief/05-internals/02-adapters.md b/.abcd/development/brief/05-internals/02-adapters.md index fc642283c..145e47bbb 100644 --- a/.abcd/development/brief/05-internals/02-adapters.md +++ b/.abcd/development/brief/05-internals/02-adapters.md @@ -283,8 +283,15 @@ 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 +Two providers ship: an assets-only Cloudflare Worker, the host abcd's own site +uses, and GitHub Pages, which publishes a project's site from its own workflow +with the run's short-lived token and no credential. Each says in its profile +what the verb and the workflow need beyond the deploy step: the deploy +environment and its token permissions, whether a publish switch gates it, +whether it serves public repositories only, the path the site is served below, +and whether it publishes a project site, built from nothing but what abcd +writes ([`04-surfaces/22-site.md`](../04-surfaces/22-site.md#a-projects-own-site)). +Another provider 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 the credential store (`internal/core/credential`), the one reader; the connected adapter holds it, and it is scrubbed from every diff --git a/.abcd/development/brief/06-delivery/03-out-of-scope.md b/.abcd/development/brief/06-delivery/03-out-of-scope.md index 30a007125..8d5bf7c8a 100644 --- a/.abcd/development/brief/06-delivery/03-out-of-scope.md +++ b/.abcd/development/brief/06-delivery/03-out-of-scope.md @@ -147,6 +147,7 @@ That is what keeps "not hand-counted" true after the day it was written. - `itd-2610161647248332` — abcd finds out what the forge can do before choosing a route - `itd-2610161710315026` — Unattended runs reach only a declared list of hosts - `itd-2610161710316538` — Drains and builds a person's session drives report how isolated they were +- `itd-2610161904178040` — People who think in Emacs can edit the brief with Emacs keys - `itd-2610170634394993` — Headless agents in parallel coordinate through the shared remote, not conversation diff --git a/.abcd/development/decisions/adrs/2610032150581128-abcd-is-reachable-on-a-network-only.md b/.abcd/development/decisions/adrs/2610032150581128-abcd-is-reachable-on-a-network-only.md index 4dcf5fcbb..e3f6abb6a 100644 --- a/.abcd/development/decisions/adrs/2610032150581128-abcd-is-reachable-on-a-network-only.md +++ b/.abcd/development/decisions/adrs/2610032150581128-abcd-is-reachable-on-a-network-only.md @@ -5,9 +5,9 @@ status: accepted date: 2026-10-03 supersedes: null superseded_by: null -related_intents: [itd-2610032150577708, itd-2610032150580455, itd-2610040740108331] +related_intents: [itd-2610032150577708, itd-2610032150580455, itd-2610040740108331, itd-2610091016262335] related_rfcs: [] -related_adrs: [adr-38, adr-2609091248200336] +related_adrs: [adr-38, adr-2609091248200336, adr-2610091016270711, adr-2610161400542916] --- # ADR-2610032150581128: abcd is reachable on a network only through the dashboard, only while the person runs it, and only for people the person lets in @@ -49,6 +49,27 @@ _From the security review of 2026-10-03 (Fable 5.1, verdict "block as drafted" u - On the local network it runs in the foreground, stops itself when idle and after a hard limit, and says so; running unattended is allowed only behind Tailscale Serve (never Funnel), where pairing still applies and the Tailscale identity is a label, never a key. - The start line says the page is only as private as the network: anyone who can read the Wi-Fi can read it. The person confirms on each run that the network is their own. +### Amendment of 2026-10-14: the brief session's relay is the second listener + +Amended in the open, as the second bullet above requires, by the change that lands `abcd session serve` (itd-2610091016262335, spc-2610141709430146). On 2026-10-14 the product thinker ruled that a team's live brief session runs on the team's own machine over Tailscale or on the team's own relay ("we offer tailscale and the rascals pattern as build-in defaults"). They also signed off on the relay living inside the abcd binary. Both rulings are recorded in itd-2610091016262335's Decisions and in adr-2610091016270711. That makes a second listener the product thinker's decision in substance. This amendment writes down the terms it runs under, which the facilitator settled on delegation: + +- **It is the session relay and nothing else.** `internal/surface/session` is the second package that may open a listener, beside `internal/surface/dashboard`. `TestOnlyTheDashboardOpensAListener` becomes a two-entry allowlist, and each entry cites this record. +- **It listens only while someone has started it on purpose,** on loopback unless an address is chosen on that run. A non-loopback address (`--listen`) is the second, explicit choice the security review requires. It is meant for the team's own relay deployment, which runs behind its host's proxy (`--behind-proxy`). +- **On a person's own machine, it is reached through Tailscale Serve, on the tailnet only, and never through Funnel.** Decision 21's objection to Serve (that Serve hides which device connects) does not bind here, because the relay never admits anyone by device identity. Admission is by the host's join code, and team membership is by GitHub's own answer on the presented token. Funnel would expose the person's computer to the internet, so a guest outside the tailnet joins through the team's relay instead. +- **It holds no project content at rest.** A session's edits are opaque bytes in memory, dropped when the host closes the session or it goes idle. Nothing is written to disk, and no token, secret or brief text is logged. +- **The rest of this record holds for it unchanged:** limits on message size, connections and sessions; refusal of unexpected origins; and nothing third-party served from the person's computer. + +This refines the dashboard's rule and does not relax it. The dashboard stays Tailscale-address-only and never uses Serve or Funnel. + +### Amendment of 2026-10-16: a local session's relay serves its own page + +Amended in the open by adr-2610161400542916. On the host's own computer, `abcd session start` runs the session relay and also serves the session page from it, so nothing reaches abcdesign.app. Every term of the 2026-10-14 amendment holds, with these additions: +- It answers only its own tailnet name and loopback, as hosts and as page origins. Any other host is refused before anything is read. +- It serves the page's embedded files from a fixed route table. No file is served from a requested path. +- The page is never framed. + +Team membership is still decided by GitHub's answer on a GitHub App user access token, now obtained through GitHub's device flow, with no secret on the host's computer. Tailscale Serve's identity headers stay a label the relay does not read, for the reasons that record gives. Funnel stays out. Someone outside the tailnet reaches a local session only when the host shares the computer with them through Tailscale, and joins it as a guest. + ## Alternatives Considered - Never listen, and publish a static site instead (the superseded itd-139's route), or rebuild the existing record site on change and serve the folder: rejected by the record review of 2026-10-03. The site build needs a composition only `site setup` plants, takes minutes, and rewrites a committed file on every build; it shows the facilitator's tiles and record ids, not what waits on the product thinker; and serving the folder still needs a listener and this rule. diff --git a/.abcd/development/decisions/adrs/2610091016270711-who-may-change-an-abcd-managed-project-s.md b/.abcd/development/decisions/adrs/2610091016270711-who-may-change-an-abcd-managed-project-s.md index d2df5f185..efa5b6e8e 100644 --- a/.abcd/development/decisions/adrs/2610091016270711-who-may-change-an-abcd-managed-project-s.md +++ b/.abcd/development/decisions/adrs/2610091016270711-who-may-change-an-abcd-managed-project-s.md @@ -7,7 +7,7 @@ supersedes: null superseded_by: null related_intents: [itd-2610091016262335, itd-2610052000411162, itd-2610040740122709, itd-2610040740135705] related_rfcs: [] -related_adrs: [adr-2610032150581128] +related_adrs: [adr-2610032150581128, adr-2610161400542916] --- # ADR-2610091016270711: Who may change an abcd-managed project's design on abcdesign.app, and how a change lands @@ -43,6 +43,14 @@ Decided by the product thinker's rulings of 2026-10-14, recorded verbatim in itd 3. **A change lands only through GitHub, as a proposal the team decides on.** The team chooses, per session, between each edit as its own pre-filled GitHub issue (credited to its author or approver) and the whole session as one pull request (authored by the submitter, with a co-author line per credited team member). abcd never writes into the repository itself. The team's own agent session picks these up at its start, the way the inbox surfaces reports; a GitHub Action on the project's own key is a later addition. 4. **Scope of the first release:** the brief only, public and private repositories alike, with no product thinker / technical facilitator toggle. abcdev.app is read-only and offers no editing. +### Amendment of 2026-10-16: no token is pasted, and the page finds its relay itself + +Amended by adr-2610161400542916, from the product thinker's rulings of 2026-10-16 (itd-2610091016262335's Decisions). In decision 2, the fallback of pasting a personal access token is withdrawn: the page has no token field, and the relay takes only a GitHub App user access token. A team member signs in with GitHub through the team's own GitHub App, in one of two ways: +- the web flow, through the team's cloud relay, which holds the app's secret; +- GitHub's device flow, for a session on the host's own computer, which holds no secret. + +Decision 1's two ways to host stand. On the host's own computer, that computer also serves the page, so nothing goes to abcdesign.app. + ## Alternatives Considered - **A relay abcd runs.** Rejected: abcd never runs a server holding or serving project content ("not us. never."). @@ -57,4 +65,5 @@ Decided by the product thinker's rulings of 2026-10-14, recorded verbatim in itd - Each team does a one-off setup: deploying the relay or joining the host's Tailscale network, and registering its own GitHub sign-in app. Keeping that relay and its secret safe is the team's responsibility, not abcd's. - Anyone the host gives the code to sees the brief, a private one included; admission of guests is the host's call. - The session needs per-person change tracking and a suggestion mode for guests. -- abcd adds no network surface of its own: its only network surface stays the local dashboard (adr-2610032150581128), and the hosted page at abcdesign.app is static. +- The page is served from a static host, which cannot send `frame-ancestors` or `X-Frame-Options`, and a `` Content-Security-Policy cannot carry `frame-ancestors`. This is accepted (security review 2, finding 11b, 2026-10-15), with mitigations: the page refuses to run in a frame and shows only a link to open it in its own tab, so its relay confirmation and sign-in are never drawn where another page could overlay them; the sign-in itself is on github.com, which refuses to be framed; and sign-in goes through a GitHub App alone, whose token reaches only the repositories the app is installed on. +- abcd runs no network surface of its own anywhere. The relay is a verb in the abcd binary that the team starts on its own machine or deploys to its own account, admitted as the second listener by the 2026-10-14 amendment of adr-2610032150581128. The hosted page at abcdesign.app is static. diff --git a/.abcd/development/decisions/adrs/2610141949110653-the-brief-session-s-browser-editor-ships.md b/.abcd/development/decisions/adrs/2610141949110653-the-brief-session-s-browser-editor-ships.md new file mode 100644 index 000000000..503885119 --- /dev/null +++ b/.abcd/development/decisions/adrs/2610141949110653-the-brief-session-s-browser-editor-ships.md @@ -0,0 +1,41 @@ +--- +id: adr-2610141949110653 +slug: the-brief-session-s-browser-editor-ships +status: accepted +date: 2026-10-14 +supersedes: null +superseded_by: null +related_intents: [itd-2610091016262335] +related_rfcs: [] +related_adrs: [adr-47, adr-2610091016270711] +--- + +# ADR-2610141949110653: The brief session's browser editor ships as a vendored bundle the binary embeds + +## Context + +itd-2610091016262335 has a team editing its brief live in the browser. Live co-editing of raw Markdown needs a CRDT and an editor. The research note 2026-10-14-live-co-editing-to-one-pull-request.md picked Yjs v13 with CodeMirror 6 through y-codemirror.next: the shared text is the Markdown file byte for byte, and these are the most-used bindings. Writing either library from scratch is out of proportion to the intent. + +adr-47 keeps a Node toolchain out of this repository. No `package.json`, lockfile or npm step runs in CI or in a build. The site build depends only on the binary. On 2026-10-14 the product thinker signed off on vendoring the two libraries. They also ruled that a technical choice grounded in state-of-the-art practice needs no further sign-off. + +## Decision + +We will ship the editor's third-party code as one prebuilt ES module, `internal/surface/session/web/vendor/collab.js`. It is committed beside its build entry module (`collab.entry.js`), a `PROVENANCE.json` and a `LICENSES.txt`, and the binary embeds it. + +1. **The bundle is built outside the repository.** The build installs exact versions with install scripts off. It resolves only releases published at least two weeks before the build, and bundles with a pinned esbuild. The build is reproducible: two runs give the same bytes. +2. **Provenance is committed and checked.** `PROVENANCE.json` names every bundled package, its version, its npm integrity hash and its tarball URL. It also records the bundler and command, the entry module's SHA-256 and the bundle's SHA-256. A Go test fails when the committed bytes no longer match the recorded hashes. +3. **Licences travel with it.** `LICENSES.txt` carries every bundled package's licence, all MIT. Where a package ships no licence file, its source copyright header is used instead. +4. **A bump repeats the build in full.** It uses a new cut-off date and rewrites all four files in one change. Nothing in the repository runs npm. + +## Alternatives Considered + +- **A committed `package.json` and lockfile, built in CI.** Lockfile integrity is the stronger supply-chain control, but it brings the Node toolchain adr-47 rules out, and a CI build step on every release. Rejected for now. A later ADR may revisit adr-47 for this. +- **Loading the libraries from a CDN at runtime.** This adds no repository weight, but every session would then send a request to a third party abcd chose. The page's integrity would also depend on the CDN's availability. Rejected. +- **A plain textarea with hand-written merging.** This needs no third-party code, but merging concurrent edits correctly is the CRDT problem itself. Rejected. +- **The vendored, provenance-pinned bundle (chosen).** No toolchain enters the repository. The bytes are auditable and rebuildable. The cost is a committed 720 KB minified file and a manual bump. + +## Consequences + +- The binary grows by the bundle's size, about 720 KB, or 250 KB gzipped. +- A security fix in a bundled package reaches users only through a deliberate rebuild and a release. Dependabot cannot see the bundle, so the bump is a facilitator's task. The provenance file makes the bump auditable. +- Reviewers cannot read a minified bundle. They review the entry module, the provenance and the hash test instead, and anyone can rebuild the bundle and compare hashes. diff --git a/.abcd/development/decisions/adrs/2610142147242544-abcd-s-site-is-built-onto-its-own-github.md b/.abcd/development/decisions/adrs/2610142147242544-abcd-s-site-is-built-onto-its-own-github.md new file mode 100644 index 000000000..4defc0b51 --- /dev/null +++ b/.abcd/development/decisions/adrs/2610142147242544-abcd-s-site-is-built-onto-its-own-github.md @@ -0,0 +1,48 @@ +--- +id: adr-2610142147242544 +slug: abcd-s-site-is-built-onto-its-own-github +status: proposed +date: 2026-10-14 +supersedes: null +superseded_by: null +related_intents: [itd-2610142145236613, itd-2610091010348892] +related_rfcs: [] +related_adrs: [adr-47, adr-48, adr-2610091016270711] +--- + + +# ADR-2610142147242544: abcd's record is built onto its own GitHub Pages, and abcdev.app becomes abcd's documentation site + +## Context + +adr-48 deploys abcdev.app to a Cloudflare Worker from released bytes at each release, record and docs together, with a preview of `main` on every push. itd-2610091010348892 gives every opted-in project a record built onto its own GitHub Pages by a workflow abcd writes. The product thinker made three rulings: +- On 2026-10-14, that abcd's own repository moves to that route ("so that it no longer has to deploy to cloudflare"). +- On 2026-10-15, that a site renders only what abcd writes to a repository anyway, with docs read on GitHub by default. +- Also on 2026-10-15, that abcd's own docs are rendered in high quality on abcdev.app until a community site arrives, and that abcdev.app is abcd's docs site while abcdesign.app is where a team edits its brief, for any abcd-managed repository. + +The move is planned in itd-2610142145236613 and built later. + +## Decision + +Proposed with the plan. It is accepted in the change that lands spc-2610142146250176's third step, when the code that enforces it lands. + +1. **abcd's about page and record are built and deployed by the workflow `site setup --provider github-pages` writes for any project,** from what abcd writes and nothing else. abcd's own needs are met inside that route, not beside it. +2. **abcdev.app is abcd's documentation site until a community site replaces it.** Its deploy (adr-48: on release, from released bytes, with the preview of `main`) narrows to the documentation and its landing page. The record explorer leaves it. +3. **The record's old addresses on abcdev.app (`/record/`, `/contributors/`, `/references/`) forward with a 301 to the same path on abcd's GitHub Pages address.** The forwarding is declared in the docs host's redirect file in the repository. abcd's own links point at the Pages address directly. +4. **What GitHub Pages cannot carry is named, not hidden.** On the project route, the document content policy moves into each page as an element. Framing refusal (`frame-ancestors`) and `X-Content-Type-Options: nosniff` are lost there. The record loads nothing from outside itself and holds nothing a framing page could act on, so the loss is accepted for a read-only record. abcdev.app keeps its response headers on its own host. +5. **The static abcdesign.app page is published from a repository of its own,** because a repository has one Pages site and abcd's is the record. + +## Alternatives Considered + +- **Keep the Cloudflare deploy as it is (adr-48 unchanged).** It keeps everything in one place. But abcd's record would not run on the route abcd offers, against the product thinker's 2026-10-14 ruling. +- **Move everything, docs included, onto abcd's GitHub Pages and forward all of abcdev.app.** This was the plan of 2026-10-14. The single-source ruling of 2026-10-15 rules out rendering hand-written docs on a project site, and the product thinker kept abcdev.app for abcd's docs. +- **Read abcd's docs on GitHub, like any project's.** That is the default for every other repository. The product thinker chose a high-quality docs site for abcd itself, for now. +- **The record on the project route, abcdev.app narrowed to the docs, and the record's old addresses forwarded (chosen).** + +## Consequences + +- abcd's record depends on GitHub Pages' availability and limits (1 GB per site). The docs stay on their current host. +- Every project on the github-pages provider gains the page-carried content policy that abcd's record needs. +- abcdev.app's build drops the record explorer, and its redirect file gains the record's forwards. Cloudflare keeps serving the docs only. +- abcdesign.app's publishing repository is a new outward act at that intent's cut. +- When a community site replaces abcdev.app, the docs move with it, by a later decision. diff --git a/.abcd/development/decisions/adrs/2610161400542916-the-brief-session-page-asks-nobody-for-a.md b/.abcd/development/decisions/adrs/2610161400542916-the-brief-session-page-asks-nobody-for-a.md new file mode 100644 index 000000000..af7c27007 --- /dev/null +++ b/.abcd/development/decisions/adrs/2610161400542916-the-brief-session-page-asks-nobody-for-a.md @@ -0,0 +1,106 @@ +--- +id: adr-2610161400542916 +slug: the-brief-session-page-asks-nobody-for-a +status: accepted +date: 2026-10-16 +supersedes: null +superseded_by: null +related_intents: [itd-2610091016262335] +related_rfcs: [] +related_adrs: [adr-2610091016270711, adr-2610032150581128, adr-2610141949110653] +--- + +# ADR-2610161400542916: The brief session page asks nobody for a relay address or a token, and a local session signs in by GitHub's device flow + +## Context + +The product thinker tested the draft brief session page on 2026-10-16 and could not use it (iss-2610161332024554). Its first screen asked for a "relay address", then for a pasted GitHub personal access token. The brief appeared only after hosting and picking a file. The page's audience is product thinkers and domain experts, not the people who set the project up. + +The same day the product thinker approved a new design. It is recorded, restated, in itd-2610091016262335's Decisions: + +- The page never shows or asks for a relay address. It never asks the person for technical input of any kind. +- Nobody pastes a token. The paste is removed from the page and from the relay. +- Once a session is joined, the page opens straight onto the brief, press release first, in plain words. +- There are two ways to host, and both stay. In the cloud, the team's relay runs on its own hosting account and the page is at abcdesign.app; members use "Sign in with GitHub". Locally, for sensitive projects, the session runs on the host's own computer, reached over Tailscale Serve on the tailnet only. That computer runs the relay and serves the page itself, so nothing goes to abcdesign.app. Sign-in uses GitHub's device flow, so no secret sits on the host's computer. +- Org members sign in and edit. Everyone else joins as a guest with a name and suggests changes, as the draft has it. + +adr-2610091016270711 decision 2 kept "pasting a personal access token is the fallback". The 2026-10-14 amendment of adr-2610032150581128 admits the relay as abcd's second listener and has team membership decided "by GitHub's own answer on the presented token". The spec had the page read the relay address from a field or a link, and confirm by showing its origin. This record replaces those parts. It keeps every other term of both records. + +Three technical questions had a state-of-the-art answer, and the product thinker allows those to be decided on their grounds. + +## Decision + +We will build the session as follows. + +1. **The page finds its relay itself.** + - Served by the relay (a local session), the page takes its own origin as the relay. The page and the relay are one server, so there is nothing to check. + - On abcdesign.app, the page uses the relay the invite link names, if the repository's committed `.abcd/relay/relays.json` names it. With no relay in the link, it uses the first relay the file names. + - Where the file cannot be read without signing in (a private repository), the page asks one plain question before anything reaches the link's relay: whether someone on that project's team sent the link. A relay the person confirmed is remembered for that repository. (**Amended 2026-10-16:** for the browser tab and at most twelve hours; see the amendment below.) + - With no relay anywhere, the page says that live sessions are not set up for this project yet, and to ask the technical facilitator. + - No relay address appears on the page, in its words, in a field, or as link text. + + The trust rules of the draft hold: the repository is the anchor (security review F1); the list is read fail-closed (finding 5); a list the token can see decides; and the invite link carries a relay only when the guest's page could not find the same one itself. +2. **No token is pasted anywhere.** The page has no token field. The relay's `verify` takes only a GitHub App user access token, whose documented prefix is `ghu_` (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#githubs-token-formats). It refuses any other token before asking GitHub, so a personal access token, an OAuth app's token or an installation token is never accepted. +3. **The relay offers one of two sign-ins, chosen by how it is set up.** Given the GitHub App's client id and secret, it offers GitHub's web flow, as the draft does (`/auth/token`). Given the client id alone, it offers GitHub's device flow: + - `/auth/device` asks GitHub for a device code and keeps it in memory under a random handle. It returns only the handle, the user code, GitHub's verification address and the timings. + - `/auth/device/token` polls GitHub for that handle, no faster than GitHub's interval, and returns the token once the person has confirmed the code on github.com. + - The device code never reaches the browser. Both routes check the page's origin, and starting a device sign-in is limited per address as the exchange is. + - The relay holds a bounded number of pending sign-ins, each dropped when GitHub's code expires. +4. **A cloud relay keeps the web flow** (decision on state-of-the-art grounds, below). +5. **A local session is one command, `abcd session start`.** It runs the relay on loopback with the device flow and serves the page from the relay itself. It admits only its own tailnet name and loopback, as hosts and as page origins. It prints the one Tailscale Serve step and the link to open. It reads the GitHub App's public client id from `.abcd/relay/github-app.json`, which repository setup writes, or from `--client-id`. It holds no secret. +6. **The relay does not use Tailscale Serve's identity headers** (decision on state-of-the-art grounds, below). + +### Decided on state-of-the-art grounds + +- **Cloud sign-in stays on the web redirect flow.** GitHub's device flow needs no client secret ("The `client_secret` is not needed for the device flow", https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps). GitHub's web flow still requires the secret for a GitHub App, PKCE or not (https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). GitHub has no public-client concept, and its roadmap item for single-page-app support was paused on 2026-08-13 (https://github.com/github/roadmap/issues/1153). So the device flow would free the cloud relay of its one secret. It is still not chosen there, for three reasons: + - It is open to remote phishing (RFC 8628 §5.4, https://www.rfc-editor.org/rfc/rfc8628#section-5.4). Anyone can start a device flow with the team's public client id and ask a member to enter the code. The web flow's redirect is bound to the app's registered callback, so a stolen code is useless without the secret. **Amended 2026-10-16:** that holds only where the registered callback is the relay that exchanges the code; see the amendment below. + - GitHub limits device-flow code submissions to 50 an hour per application. + - It sends every member to a second tab to type an eight-character code, where the web flow is one click. + + RFC 10017 (OAuth 2.0 for Browser-Based Applications, BCP 212) recommends a backend that holds the client's credentials. The relay's `/auth/token` is that backend, and it already holds the secret on the team's own account. On the host's own computer the trade reverses: the product thinker ruled that no secret sits there, so the device flow is the only GitHub sign-in that fits. +- **Tailscale Serve's identity headers are not used.** Serve adds `Tailscale-User-Login`, `Tailscale-User-Name` and `Tailscale-User-Profile-Pic` for a device that is not tagged, shared-in users included, and strips copies a client sends (https://tailscale.com/kb/1312/serve). They are still not used, for three reasons: + - A Tailscale login is not a GitHub login, and it says nothing about write access to the repository, which is what membership means here. + - The relay listens on loopback. Tailscale's page notes that a service on loopback can still be reached, headers and all, by other programs on the same computer. + - adr-2610032150581128 already rules that a Tailscale identity is a label, never a key. + + Membership stays GitHub's answer on a GitHub App token. A guest's name stays the one they type. + +### Amendment of 2026-10-16: the device flow is offered only where the relay serves its own page + +Amended in the open, on review of the change that lands decision 3. Every term above holds, with these narrowings: + +- Decision 3's device flow is offered only by a relay that serves its own session page, which is `abcd session start` (decision 5). `abcd session serve`, the team's relay, offers the web flow when it holds the secret and no sign-in without it: its `/config` reports sign-in off, and `/auth/device` answers that the device flow is not offered. +- The session page takes a relay's device flow only on a page that relay serves itself. Every local session serves its own page, so the page on abcdesign.app, or on any other origin, signs in through the web flow alone, and a relay that names the device flow there gets no sign-in. +- Beside the bound on pending device sign-ins, one client address holds at most four at once, counted as the exchange limit counts it and never more than that limit allows, so a few addresses cannot take every place. + +### Amendment of 2026-10-16: the sign-in code goes only to the relay the app's callback names + +Amended in the open, on review of the change that lands decision 3's web flow. It corrects a premise above and narrows decisions 1 and 3. + +The premise was that the web flow's redirect, bound to the app's registered callback, keeps the code with the team's relay. With the session page as the registered callback, GitHub binds the code to the page, and the page then handed it to whichever relay it had been started with, which could be a relay the person had confirmed but GitHub had no part in naming. The binding stopped at the page. So: + +1. **The callback is the relay's own.** The team's GitHub App lists `https:///auth/callback` as its callback URL, and the page sends that as the `redirect_uri`, with the page's own address carried in the state. GitHub returns the person to the relay; the relay sends them back to the page, naming its own origin, and only for a session page on an origin it admits and a repository it serves. The page posts the code and its verifier to that relay alone, and refuses a return that names no relay, or one other than the relay the sign-in started with; it never takes the exchange's relay from the invite link or its storage. The relay's `/auth/token` exchanges only a code given for its own callback. +2. **The token goes only to the relay GitHub returned to.** Decision 1's confirmation, for a relay the repository's list cannot speak for, still lets that relay run the web sign-in, so the members of a private repository sign in on abcdesign.app: the callback binding in 1 is the control, since GitHub sends the code only to a callback the team's app lists. The page presents the token for verification only to the relay the sign-in returned through, never to a relay an invite link named that GitHub did not return the person to. The confirmation is no longer remembered for the repository: it is kept for the browser tab and lapses after twelve hours, a session's longest life. +3. **Setup names the callback.** `abcd session setup --relay-origin` and `abcd session blueprint --relay-origin` print the exact callback URL to list, with wildcard matching off; a manifest posted once the relay's address is known sets it. For an app recorded before, setup also says to remove the session page's own address from the app's callback URLs if it is listed, since an earlier setup listed it. +4. **A stopped device sign-in ends at once.** The page tells the relay (`/auth/device/cancel`) when the person stops a device sign-in or leaves the page, and the relay frees its place. An address refused for holding its share is told, in `Retry-After`, when its first sign-in ends, and the page says too many sign-ins are in progress and to wait a few minutes. +5. **The callback forwards only to the page's own base path.** Each origin the relay admits may name the base path its session page is served below (`https:////`, as `abcd session page --base-path` writes it); an entry naming none, and the default origins, mean the root. The callback forwards a code only to a session page under exactly that path on that origin, since a static origin can carry other publishers' pages beside the session page. The base path is part of the origin entry, not a separate relay-wide setting: it belongs to one place the page is published, and one relay may admit the page at abcdesign.app's root and a team's own copy below a project path. +6. **The callback is spelled as the browser spells an origin.** The relay's address drops the scheme's default port (443 for https), as a browser's origin does (https://url.spec.whatwg.org/#origin), so the callback setup names matches, exactly, the `redirect_uri` the page sends. + +**Decided on state-of-the-art grounds:** the relay's own callback rather than the page's address with the relay in its query. GitHub compares a `redirect_uri` with a registered callback exactly only when that callback's wildcard matching is off, and otherwise by host, port and a path beneath the callback's ("When wildcard matching is disabled, the redirect URL must exactly match the callback URL", https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). Its user-token page says a `redirect_uri` "can't contain any additional parameters" and says nothing about a query in a registered callback (https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). A relay's own host is compared under either mode, so the binding does not depend on undocumented query matching. The authorization code then reaches only a destination GitHub binds, as OAuth 2.0's security best current practice asks of redirect URIs (RFC 9700 §2.1, https://www.rfc-editor.org/rfc/rfc9700#section-2.1). + +## Alternatives Considered + +- **Keep the relay field and the token paste, with better words.** This is the least work, but it leaves a product thinker facing a server address and a secret. The product thinker rejected it. +- **Device flow everywhere, so no relay holds a secret.** It removes the cloud relay's one secret. It is rejected for the cloud on the grounds above, and chosen for local sessions, where the product thinker ruled out a secret. +- **A shared GitHub App owned by abcd, so a local session needs no app registration.** The device flow needs a client id, and a client id is not a key. But GitHub only lets an app's token reach repositories where the app is installed (https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-with-a-github-app-on-behalf-of-a-user), so each organisation would still install it. abcd would then own a registration that every team's sign-in depends on. That is a product decision, not taken here: it is put back to the product thinker. **Amended 2026-10-16:** the product thinker ruled that each team registers its own sign-in app at repository setup, for local sessions as for the cloud, and that abcd owns no shared app (itd-2610091016262335's Decisions). `abcd session setup` registers it from a pre-filled manifest. +- **Admit tailnet visitors as members by their Tailscale identity.** Rejected on the grounds above. +- **The chosen design:** the page finds its relay, nobody pastes anything, the cloud uses the web flow and a local session uses the device flow. + +## Consequences + +- A product thinker sees the brief's name, a sign-in button and, with an invite, a name field. Once joined, they see the press release. +- **Local sessions still need a GitHub App, registered once.** The device flow needs the client id of a GitHub App with "Enable Device Flow" switched on. A manifest cannot switch that on (https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest), and the app must be installed on the repository. No secret is kept and no server is deployed. But "no account and no cloud setup" overstates it: someone who may create apps for the organisation does this once. Repository setup (a later lane) carries that step. +- A team that allows both kinds of session uses one app, with the device flow switched on. That leaves the cloud open to the device-flow phishing above. The setup step switches the device flow on only when local sessions are allowed, and says why. +- The relay holds a little more state: pending device sign-ins, bounded in number and lifetime, in memory only, never logged. +- A pasted personal access token no longer works anywhere. A team without a GitHub App cannot sign in, and the page tells them, in plain words, to ask their technical facilitator. +- adr-2610091016270711 and adr-2610032150581128 each carry an amendment pointing here. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index d372cbac8..b8776c479 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -211,8 +211,11 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2610120558328615](2610120558328615-a-private-advisory-s-record-never-enters.md) | A private advisory's record never enters the public ledger, its store is never committed, and nothing public carries an advisory id or hint | accepted | 2026-10-12 | | [adr-2610120937435856](2610120937435856-a-cloud-session-s-unchecked-work-goes.md) | A cloud session's unchecked work goes only to a declared work-in-progress prefix that never reaches the default branch | accepted | 2026-10-12 | | [adr-2610140859394097](2610140859394097-abcd-s-home-ignores-itself-once-and.md) | abcd's home ignores itself once, and credentials never sit in a working tree | proposed | 2026-10-14 | +| [adr-2610141949110653](2610141949110653-the-brief-session-s-browser-editor-ships.md) | The brief session's browser editor ships as a vendored bundle the binary embeds | accepted | 2026-10-14 | +| [adr-2610142147242544](2610142147242544-abcd-s-site-is-built-onto-its-own-github.md) | abcd's record is built onto its own GitHub Pages, and abcdev.app becomes abcd's documentation site | proposed | 2026-10-14 | | [adr-2610150603484802](2610150603484802-a-fresh-install-s-facilitator-is-a.md) | A fresh install's facilitator is a person until the repository says auto | accepted | 2026-10-15 | | [adr-2610150729333337](2610150729333337-a-status-branch-moves-without-a.md) | A status branch moves without a preflight receipt | proposed | 2026-10-15 | +| [adr-2610161400542916](2610161400542916-the-brief-session-page-asks-nobody-for-a.md) | The brief session page asks nobody for a relay address or a token, and a local session signs in by GitHub's device flow | accepted | 2026-10-16 | | [adr-2610161621415708](2610161621415708-abcd-s-command-guard-steers-agents-an-os.md) | Unattended runs abcd launches execute inside an OS sandbox | proposed | 2026-10-16 | | [adr-2610161733400551](2610161733400551-a-reviewer-in-a-model-matrix-run-never.md) | A reviewer or judge in a model-matrix run is told nothing about which model wrote the copy it judges | proposed | 2026-10-16 | diff --git a/.abcd/development/intents/drafts/itd-2610161904178040-people-who-think-in-emacs-can-edit-the.md b/.abcd/development/intents/drafts/itd-2610161904178040-people-who-think-in-emacs-can-edit-the.md new file mode 100644 index 000000000..ffb994576 --- /dev/null +++ b/.abcd/development/intents/drafts/itd-2610161904178040-people-who-think-in-emacs-can-edit-the.md @@ -0,0 +1,43 @@ +--- +id: itd-2610161904178040 +slug: people-who-think-in-emacs-can-edit-the +spec_id: null +kind: null +suggested_kind: null +reclassification_history: [] +builds_on: [] +severity: minor +origin: researcher-authored +production_mode: hand-written +blocked_by: [itd-2610091016262335] +--- + +# People who think in Emacs can edit the brief with Emacs keys + +## Press Release + +> People who think in Emacs can edit the brief with Emacs keys. On abcdesign.app, anyone in a live brief session can switch the brief editor to Emacs keys in the page's settings; it is off unless they turn it on, and it is remembered for them on that browser. With it on, the editor works the way an Emacs user's hands expect on every computer, Mac, Windows or Linux: Ctrl-Space sets the mark, Ctrl-W and Alt-W cut and copy, Ctrl-Y pastes, Ctrl-S searches, Ctrl-A and Ctrl-E go to the start and end of a line, Ctrl-K cuts to the end of the line. Alice, a solo founder who writes everything in Emacs, turns it on once and edits the press release without reaching for the mouse; Bob, who has never used Emacs, never sees it unless they turn it on. + +## Why This Matters + +> _Why this matters to the user — replace before planning._ + +## Mechanism + +> _Prompted (the claim-recording gradient): why the authors expect this to work, as a falsifiable "we expect X because Y" — not the outcome restated. Replace this line with the claim, or with the exact token `None stated.` alone on its line to record the claim as considered and declined._ + +## Scope Conditions + +> _Required (the claim-recording gradient): the population, platform, scale, or assumptions this claim holds under, one per top-level bullet — `abcd intent plan` stamps each with a persistent identity. Replace this line with those bullets, or with the exact token `None stated.` alone on its line._ + +## Acceptance Criteria + +> _Required (the itd-1 discipline): add at least one Given-When-Then bullet describing the verifiable bar for "shipped" before this draft can be planned._ + +## Open Questions + +_None recorded yet._ + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ diff --git a/.abcd/development/intents/planned/itd-2610091010348892-anyone-who-opens-abcdev-app-finds-the.md b/.abcd/development/intents/planned/itd-2610091010348892-anyone-who-opens-abcdev-app-finds-the.md index b756ac612..65df77051 100644 --- a/.abcd/development/intents/planned/itd-2610091010348892-anyone-who-opens-abcdev-app-finds-the.md +++ b/.abcd/development/intents/planned/itd-2610091010348892-anyone-who-opens-abcdev-app-finds-the.md @@ -9,7 +9,7 @@ builds_on: [itd-135] severity: minor origin: researcher-authored production_mode: hand-written -related_intents: [itd-2610052000411162] +related_intents: [itd-2610052000411162, itd-2610142145236613] related_adrs: [adr-47, adr-2610091010387033] --- @@ -17,7 +17,7 @@ related_adrs: [adr-47, adr-2610091010387033] ## Press Release -> When a project switches it on at setup, abcd's release workflow builds the project's about page and development record at each release and publishes them on the project's own GitHub Pages, for example acme.github.io/widgets/. Every page opens with a line saying it is the record of an abcd-managed repository, linking to the repository, and every word on it comes from the repository. The project's own workflow builds it and GitHub hosts it; abcd runs nothing. abcd's own record moves to the same route, built exactly the way any other project's is. +> When a project switches it on at setup, abcd's release workflow builds the project's about page and development record at each release and publishes them on the project's own GitHub Pages, for example acme.github.io/widgets/. Every page opens with a line saying it is the record of an abcd-managed repository, linking to the repository, and every word on it comes from the repository. The project's own workflow builds it and GitHub hosts it; abcd runs nothing. ## Why This Matters @@ -37,11 +37,11 @@ We expect owners to switch it on because it costs one answer at setup and nothin _Drafted 2026-10-14 from the product thinker's rulings; accepted as drafted the same day (Decisions)._ -- **Given** setup in a public abcd-managed repository, **when** the owner answers yes to a project site, **then** the switch is set in the project's abcd settings and abcd's release workflow is added, and nothing else in the repository changes. +- **Given** setup in a public abcd-managed repository, **when** the owner answers yes to a project site, **then** the switch is set in the project's abcd settings and abcd's release workflow is added, and nothing else in the repository changes: no file is written for the website. - **Given** a project with the switch on, **when** it publishes a release, **then** its about page and development record appear at its own GitHub Pages address, as of that release. +- **Given** any page of the site, **when** a visitor reads it, **then** every word on it comes from what abcd writes to the repository anyway (its identity block, brief, development record and changelog), and hand-written documentation is not rendered: it is read on GitHub. - **Given** any page of the site, **when** a visitor opens it, **then** it opens with a line saying it is the record of an abcd-managed repository, linking to the repository. -- **Given** abcd's own repository, **when** it moves to this route and publishes a release, **then** its record is built the same way as any other project's, and its Cloudflare deploy can be retired. -- (technical) **Given** text on a page that cannot be traced to a file in the repository, **when** the workflow builds, **then** the build fails, as the site's single-source rule already requires. +- (technical) **Given** text on a page that cannot be traced to a file abcd writes, **when** the workflow builds, **then** the build fails, as the site's single-source rule already requires; the site's interface words, styles and scripts come from the abcd binary, never from files in the repository. - (technical) **Given** the release workflow, **when** it runs, **then** it uses no secret beyond the short-lived token GitHub gives each run, and abcd runs nothing. - (technical) **Given** the owner switches the site off, **when** the next release is published, **then** nothing is published, and abcd's setup can remove the workflow. @@ -74,3 +74,10 @@ _Empty. Populated by intent-auditor when intent moves to shipped/._ - 2026-10-14: asked how abcdev.app's list learns which projects switched on, the product thinker set the question aside and reset the shape, verbatim: "this is all too complicated. Let's rethink. if a user opts-in, abcd automatically builds a github.io site with each release. We should test that with the abcd repo at one point so that it no longer has to deploy to cloudflare. The cloudflare abcdev.app will then become a polished version specifically for the abcd community (if there's a need for that); what's important is abcdesign.app, which must work for any abcd-managed repo where a team member can host a session to, initially, edit the brief only, e.g. abcdesign.app/acme/widget/brief/ (later intents/ etc., but not now). That's the most urgent thing for PTs: To be able to edit the brief; the current suggested solution (worktree) isn't ideal; we need the team to live build and/or coordinate here. If they sit in Stoa, or MS-Teams for example and work on that collaboratively." Read for this intent: a project that opts in gets a github.io site built by abcd's workflow at each release, on its own GitHub Pages; abcd's own repository is to be moved onto that route at some point, retiring its Cloudflare deploy; abcdev.app becomes, if there is a need, a polished site for the abcd community only, and no longer lists or renders other projects. The earlier rulings on listing, the opt-in signal, the front-page list and the live view for signed-in contributors are superseded; this intent's press release is rewritten at planning. - 2026-10-14: walked through the rewritten press release above, the product thinker answered "Stands as written". - 2026-10-14: offered the criteria walk on the drafted Why This Matters, Mechanism, Scope Conditions and Acceptance Criteria, the product thinker answered, verbatim: "auto-approve all". Every drafted criterion, the technical ones included, is accepted as written. +- 2026-10-14: the move of abcd's own repository to this route is split out into its own intent, itd-2610142145236613, planned now and built later. The product thinker asked, verbatim: "keep working so that we have this intent planned and ready to implement. We'll have to delay building it but I want it to be ready". The press release's last sentence ("abcd's own record moves to the same route, built exactly the way any other project's is.") and the fourth criterion ("Given abcd's own repository, when it moves to this route and publishes a release, then its record is built the same way as any other project's, and its Cloudflare deploy can be retired.") move to that intent; this intent ships without them. +- 2026-10-15: the product thinker ruled that a project's site renders only what abcd already writes to the repository, as its single source, and nothing written for the website. Pictures are the exception: abcd is later to ask the person for images, or offer to generate some, depending on the context (captured as iss-2610150529485165). Read as: setup writes no composition and no site sources into the repository; the about page is composed from abcd's own writing; the interface words, styles and scripts ship in the binary. The first and fourth criteria are tightened and a criterion is added to say so. +- 2026-10-15: asked where hand-written docs live, the product thinker ruled that an abcd-managed repository's docs are read on GitHub by default, and that abcd's own docs are rendered in high quality on abcdev.app until a community site arrives. For this intent: a project's site renders no docs; its docs are read on GitHub. + +## Grounds + +- pursued: owners switch the project site on because it costs one answer at setup and nothing to run afterwards; shown wrong if owners who say yes find the pages missing or stale after a release diff --git a/.abcd/development/intents/planned/itd-2610091016262335-the-product-thinker-and-the-technical.md b/.abcd/development/intents/planned/itd-2610091016262335-the-product-thinker-and-the-technical.md index b1e4bb96b..57cb62908 100644 --- a/.abcd/development/intents/planned/itd-2610091016262335-the-product-thinker-and-the-technical.md +++ b/.abcd/development/intents/planned/itd-2610091016262335-the-product-thinker-and-the-technical.md @@ -10,15 +10,15 @@ severity: minor origin: researcher-authored production_mode: hand-written related_intents: [itd-2610052000411162, itd-2610040740122709, itd-2610040740135705, itd-2610091010348892] -related_adrs: [adr-2610091016270711] +related_adrs: [adr-2610091016270711, adr-2610161400542916] supersedes: [itd-2610052000411162, itd-2610052000424067] --- -# A team edits its brief together live on abcdesign.app during a call +# A team edits its brief together live during a call, on abcdesign.app or on the host's own computer ## Press Release -> During a team call, a product thinker opens their project's brief at abcdesign.app///brief/ and hosts a session. Teammates join by scanning a QR code or typing a short code, from a laptop, an iPad or a phone, and everyone edits the brief together, seeing each other's changes as they type. Team members sign in with GitHub and their changes are credited to them; a guest's change waits until a team member approves it. The team decides how their work goes back: each edit as its own GitHub issue, credited to its author or to the team member who approved it, or the whole session as one pull request once they are done. The session runs on the host's own machine or the team's own relay; abcd runs and holds nothing. +> During a team call, a product thinker opens their project's brief and starts a live session. Teammates follow the invite link or scan its QR code, on a laptop, an iPad or a phone, and land straight on the brief, its press release first. Members of the project's GitHub organisation sign in with GitHub and edit directly, and each change is credited to them. A domain expert or a stakeholder from outside joins as a guest with just their name and suggests changes, which a team member applies or declines. Nobody is asked for a server address or a key. When they are done, the team sends the work back as one pull request, or as one GitHub issue per change. For most projects the session runs on the team's own relay and the page is at abcdesign.app. For a sensitive project it runs on the host's own computer, reached only over the team's private Tailscale network, and nothing goes to abcdesign.app. abcd runs and holds nothing. ## Why This Matters @@ -32,26 +32,37 @@ We expect a team to shape its brief together during a call, because the editing - An abcd-managed repository on GitHub, public or private. - A team small enough to meet in one call, in a meeting tool such as Teams or Stoa. -- The host runs the session on their own machine reachable over Tailscale, or the team has deployed abcd's relay blueprint to its own account once. +- The team set up live sessions once, at repository setup: its own GitHub App, and its own relay on its own hosting account, the host's computer on Tailscale, or both. - The brief only; intents, decision records and the brief's other surroundings are later intents. - A current browser on a laptop, an iPad or a phone. ## Acceptance Criteria -_Drafted 2026-10-14 from the product thinker's rulings; accepted as drafted the same day (Decisions)._ +_Drafted 2026-10-14 from the product thinker's rulings and accepted as drafted the same day; rewritten 2026-10-16 to the design the product thinker approved that day (Decisions). The setup criteria at the end are built by a later lane._ -- **Given** an abcd-managed repository and a team member whose machine or team relay runs a session, **when** they open abcdesign.app///brief/ and host a session, **then** the page shows the brief, a QR code and a short join code. -- **Given** a hosted session, **when** a teammate scans the QR code or types the code on a laptop, an iPad or a phone, **then** they see the brief and every participant's changes as they are typed. +- **Given** a project whose team set up cloud sessions, **when** a team member opens abcdesign.app///brief/, signs in with GitHub and starts a session, **then** they see the brief's press release, a QR code and a short join code, and nothing on the page asks them for a server address or a key. +- **Given** a project whose team set up local sessions, **when** the host runs `abcd session start` on their own computer, **then** it prints the one Tailscale step and a link; opening the link shows the same page, served from the host's computer, where signing in means confirming a short code on github.com; and nothing reaches abcdesign.app. +- **Given** a running session, **when** a teammate opens the invite link or scans the QR code on a laptop, an iPad or a phone, **then** they land on the brief's press release and see every participant's changes as they are typed. - **Given** a participant who signs in with GitHub and can change the repository, **when** they edit the brief, **then** each change is credited to them. -- **Given** a participant who joined only by code, **when** they edit the brief, **then** the change shows as a suggestion until a team member approves it, and the approval names that team member. +- **Given** a guest who joined with only their name, **when** they select a passage and suggest a change, **then** it waits until a team member applies or declines it, and the record names that team member. +- **Given** someone outside the host's tailnet with whom the host has shared the computer through Tailscale, **when** they open a local session's invite link, **then** they join it as a guest. +- **Given** a project with no live sessions set up, **when** anyone opens its brief page, **then** the page says plainly that live sessions are not set up for this project yet and to ask the technical facilitator, and it asks them for nothing. - **Given** two participants changing the same passage at the same time, **when** both changes arrive, **then** neither is lost and both see the same result. - **Given** a team that chooses to send edits one by one, **when** they send an edit, **then** it becomes its own GitHub issue credited to its author, or to the team member who approved it. - **Given** a team that chooses one pull request, **when** they finish, **then** one pull request is opened containing the session's changes and naming every credited member. - **Given** any session, **when** it runs from start to finish, **then** no request reaches a server abcd runs, and abcd holds no key. -- (technical) **Given** the team's own GitHub sign-in app, **when** anyone signs in, **then** its secret stays on the team's relay or host machine and never reaches a browser or abcd; pasting a personal access token works as the fallback. +- (technical) **Given** the team's own GitHub App, **when** anyone signs in through a cloud relay, **then** GitHub's web flow is used and the app's secret stays on that relay; **and when** anyone signs in to a local session, **then** GitHub's device flow is used and no secret exists on the host's computer. +- (technical) **Given** any token other than a GitHub App user access token, **when** it reaches the relay, **then** it is refused before GitHub is asked; and the page has no field to paste a token into. +- (technical) **Given** an invite link that names a relay the repository's `.abcd/relay/relays.json` does not name, **when** it is opened, **then** nothing is sent to that relay, and the page says the link does not lead to the project's own session, showing no address. +- (technical) **Given** a local session, **when** a request names a host or a page origin other than the computer's tailnet name or loopback, **then** it is refused before anything is read; and the page's files come from a fixed route table, never from a requested path. - (technical) **Given** a pull request from a session, **when** it is opened, **then** its commit is authored by the person who submitted it, carries a co-author line for every credited team member, and an "Approved-by:" line beside each guest's display name for each approved guest change. - (technical) **Given** a single edit too long for a pre-filled issue link, **when** the team sends it as an issue, **then** it is offered as a pull request instead, measured on the link as GitHub receives it. - (technical) **Given** the relay blueprint, **when** a team deploys it to a free account, **then** one deploy serves the team's sessions, and the relay keeps no copy of the brief after a session closes. +- (setup) **Given** repository setup on a project without live sessions, **when** it runs, **then** it asks "Set up live brief sessions?" with Yes, No and Later. Later asks again at the next setup run and when someone tries to start a session. No never asks again, and the setup step stays runnable by hand. +- (setup) **Given** a Yes, **when** setup goes on, **then** it asks "Where may sessions run?" with Local, Cloud and Both. +- (setup) **Given** Cloud, **when** the technical facilitator sets it up, **then** it takes two one-click steps: creating the team's GitHub App from a pre-filled manifest, and deploying the relay with Render's Deploy button, whose own form asks for the secrets. abcd writes the files and records the relay's address itself. +- (setup) **Given** Local, **when** the technical facilitator sets it up, **then** the team's GitHub App is created from a pre-filled manifest with no secret kept, its device flow switched on and the app installed on the repository, and abcd records its public client id itself. +- (setup) **Given** any setup, **when** it finishes, **then** abcd runs no server that holds project content or a key. ## Open Questions @@ -94,3 +105,22 @@ _Empty. Populated by intent-auditor when intent moves to shipped/._ - 2026-10-14: asked whether team sessions allow private projects in the first release, now that a session runs only on the team's own machine or relay and abcd sees nothing, the product thinker chose "Allow private projects now": the first release serves private repositories as well as public ones, which lifts, for this intent, the same day's "public projects only" ruling (made when the shared copy would have sat on a server abcd runs). The team keeps its own relay safe, and the first release handles sign-in for private repositories. - 2026-10-14: walked through the rewritten press release, the product thinker changed how the work goes back, verbatim: "the team decides: individual edits as issues, credited to the author or approver, or a collective pull request once done". The press release above carries that change; it replaces the earlier "the host sends a small change as a GitHub issue or the whole session as one pull request naming everyone" and the earlier rule that the person picks by the size of the change. - 2026-10-14: offered the criteria walk on the drafted Why This Matters, Mechanism, Scope Conditions and Acceptance Criteria, the product thinker answered, verbatim: "auto-approve all". Every drafted criterion, the technical ones included, is accepted as written. + +- 2026-10-16: the product thinker tested the draft page and could not use it. Its first screen asked for a relay address, then for a pasted GitHub token, and the brief appeared only after hosting and picking a file (iss-2610161332024554). The product thinker then approved a new design, restated here: + - **Purpose.** A maintainer invites others, such as organisation members, domain experts and stakeholders, to discuss and edit the project's brief live. At least the host signs in with GitHub. + - **Who edits.** Organisation members sign in with GitHub and edit directly. Anyone else joins as a guest with just a name and only suggests changes, which a member applies or declines, as the draft has it. + - **No relay address on screen, ever.** In a cloud session the page finds the team's relay from the repository's `.abcd/relay/relays.json` or from the invite link. Where the project has no session set up, the page says plainly that live sessions are not set up yet and to ask the technical facilitator. It never asks the person for technical input. + - **No token pasting at all,** on the page or in the relay. + - **The brief first.** The page opens straight onto the brief, press release first, once a session is joined, in plain words throughout, because its audience is product thinkers and domain experts. + - **Two ways to host, both kept.** In the cloud, the team's relay runs on its own hosting account (Render), the page is at abcdesign.app, and members use "Sign in with GitHub". Locally, for sensitive projects, the session runs on the host's own computer, reached over Tailscale Serve on the tailnet only. That computer runs the relay and serves the page itself, so nothing goes to abcdesign.app, and sign-in uses GitHub's device flow, so no secret sits on it. Outsiders reach a local session when the host shares the computer with them through Tailscale, and they join as guests. + - **Setup at repository setup.** Setup asks whether to set up live brief sessions (Yes, No or Later), then where sessions may run (Local, Cloud or Both). Later asks again at the next setup run and when someone tries to start a session. No never asks again, and the step stays runnable by hand. Cloud takes two one-click steps: create the team's GitHub App from a pre-filled manifest, and deploy the relay with Render's Deploy button, which asks for the secrets on Render's own form. abcd writes the files and records the relay's address itself, and never runs a server holding project content or a key. A later lane builds this part. +- 2026-10-16, decided by the facilitator on state-of-the-art grounds, which the product thinker allows for a technical choice (adr-2610161400542916): a cloud relay keeps GitHub's web flow, and its one secret stays on the team's relay. The device flow would remove that secret, but it is open to remote phishing with the app's public client id, GitHub limits it to 50 code submissions an hour per app, and it sends every member to a second tab. A local session uses the device flow, the only GitHub sign-in that needs no secret. Tailscale Serve's identity headers are not used: a Tailscale login says nothing about write access to the repository. +- 2026-10-16: the open question on local sessions' GitHub App (adr-2610161400542916) was put back to the product thinker: the team's own app, registered once at setup, or a shared app abcd owns, which each organisation installs. The product thinker ruled for the team's own app. Each team registers its own sign-in app at repository setup, for local sessions as for the cloud, and abcd owns no shared app. Repository setup (`abcd session setup`) registers it from a pre-filled manifest and keeps only its public client id. +- 2026-10-16, decided by the facilitator on state-of-the-art grounds: the relay's Render Blueprint is written as `render.yaml` at the repository's root. Render's Deploy button takes only the repository's URL, with an optional branch, and documents no parameter naming another Blueprint path (https://render.com/docs/deploy-to-render); Render reads the Blueprint at the repository's root by default, and only its New Blueprint form takes a custom Blueprint Path (https://render.com/docs/infrastructure-as-code). The root file is what keeps the cloud deploy one click. A root `render.yaml` the team wrote is never touched: the Blueprint then goes under `.abcd/relay/`, deployed through the New Blueprint form with its Blueprint Path, one step more. +- 2026-10-16, relayed by the coordinator on the product thinker's standing ruling that team members join easily with GitHub and that sensitive projects are usually private repositories: members of a private repository sign in on abcdesign.app. A relay the person confirmed, where the repository's relay list cannot be read, may run the web sign-in. The control is the callback: the team's app lists the relay's own callback, GitHub sends the code only there, and the page takes the exchange's relay only from that return. The token is presented for verification only to that same relay, never to a relay an invite link named that GitHub did not return the person to (adr-2610161400542916's amendment). +- 2026-10-16: the product thinker chose the page's look, restated here. Of the mockups drawn for the session page, the guided cards design is the default and the shared-presence design is the alternative, both built so the team can try one against the other. Ctrl+[ and Ctrl+] switch between them on every platform, the Mac included, never Cmd, and each viewer's choice is remembered. The accent colour is cobalt blue (#2457d6 in light, #7aa2ff in dark). Both designs have left and right sidebars that toggle (Alt+Shift+D for the documents, Alt+Shift+S for the right panel); the guided design keeps its large numbered cards and "What happens next", and the shared design drops "What happens next", puts an icon-only Invite at the end of its team bar and an icon-only Send to GitHub beside Suggestions, folds its labels as the window narrows, and gives every overlay a Close button. Both look the same on every platform: the system's own font and the page's own icons. +- 2026-10-16: the product thinker moved the design switch to Ctrl+{ and Ctrl+} (Ctrl+Shift+[ and Ctrl+Shift+], Ctrl on every platform, never Cmd), so that Ctrl+[ and Ctrl+] stay the editor's indent and outdent. On Windows and Linux the page takes those keys from the editor's fold and unfold. + +## Grounds + +- pursued: a team shapes its brief together during a call because the editing happens where the conversation already is, on any device, with nothing to install but the host's, and returns through the GitHub review the team already uses; shown wrong if teams keep editing the brief afterwards in a terminal session rather than in the call diff --git a/.abcd/development/intents/planned/itd-2610142145236613-abcd-s-own-development-record-is-built.md b/.abcd/development/intents/planned/itd-2610142145236613-abcd-s-own-development-record-is-built.md new file mode 100644 index 000000000..6354cedd9 --- /dev/null +++ b/.abcd/development/intents/planned/itd-2610142145236613-abcd-s-own-development-record-is-built.md @@ -0,0 +1,63 @@ +--- +id: itd-2610142145236613 +slug: abcd-s-own-development-record-is-built +spec_id: spc-2610142146250176 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2610091010348892] +severity: minor +impact: additive +origin: researcher-authored +related_adrs: [adr-47, adr-48, adr-2610142147242544] +production_mode: hand-written +blocked_by: [itd-2610091010348892] +--- + +# abcd's own record is built onto its GitHub Pages like any project's + +## Press Release + +> abcd's own development record is built the way any opted-in project's is. At each release, abcd's release workflow renders its about page and record onto abcd's own GitHub Pages address, from what abcd writes to the repository and nothing else, and GitHub builds and hosts it. abcdev.app becomes abcd's documentation site, rendered in high quality until a community site takes its place, and a link to the record shared before the move still lands on the same page at its new address. abcdesign.app is where a team edits its brief together, for any abcd-managed repository. + +## Why This Matters + +abcd offers every managed project a record built onto its own GitHub Pages at each release (itd-2610091010348892). abcd's own record should be built the same way, so the route abcd offers is the route abcd runs on, and abcd stops deploying to a host of its own. The product thinker, 2026-10-14, verbatim: "if a user opts-in, abcd automatically builds a github.io site with each release. We should test that with the abcd repo at one point so that it no longer has to deploy to cloudflare. The cloudflare abcdev.app will then become a polished version specifically for the abcd community (if there's a need for that)". + +## Mechanism + +We expect the move to hold because abcd's site is then built by the same workflow every opted-in project gets, so each abcd release exercises the route abcd offers. The claim fails if abcd's site needs a step no other project could declare, or if links shared before the move stop landing on their pages. + +## Scope Conditions + +- abcd's repository stays public, which the project-site route requires. +- The abcdev.app domain stays registered, at a host that builds and serves abcd's documentation and can forward the record's old addresses. +- GitHub Pages stays free for public repositories, and abcd's site fits its limits (at most 1 GB). + +## Acceptance Criteria + +- **Given** abcd publishes a release, **when** its site workflow runs, **then** abcd's about page and record appear at abcd's own GitHub Pages address, as of that release, built by the workflow that `site setup` writes for any project, from what abcd writes to the repository and nothing else. +- **Given** abcdev.app, **when** a visitor opens it, **then** it shows abcd's documentation, rendered in high quality and easy to find one's way around, until a community site replaces it. +- **Given** a link to abcd's record on abcdev.app shared before the move, **when** a visitor opens it, **then** they land on the same page of abcd's GitHub Pages site, with the path kept. +- **Given** a link to a docs address that moved under /docs/ before, **when** a visitor opens it, **then** it still lands on its page on abcdev.app. +- (technical) **Given** the move has landed, **when** the next release publishes, **then** abcdev.app's deploy builds the documentation only, and the record, about page and their pages are built only by the GitHub Pages workflow. +- (technical) **Given** any document on abcd's GitHub Pages site, **when** a browser loads it, **then** it carries the content policy the Cloudflare headers carry today, as an element in the page, and the protections GitHub Pages cannot carry (framing refusal, the no-sniff header) are named in the decision record. +- (technical) **Given** the move, **when** it lands, **then** the decision record that set up the Cloudflare deploy (adr-48) is amended by a recorded decision: abcdev.app's deploy narrows to the documentation. + +## Open Questions + +_None recorded yet._ + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ + +## Decisions + +- 2026-10-14: this intent was split out of itd-2610091010348892, whose press release ended "abcd's own record moves to the same route, built exactly the way any other project's is" and whose fourth criterion covered the move. The product thinker had said the move happens "at one point", and on 2026-10-14 asked, verbatim: "keep working so that we have this intent planned and ready to implement. We'll have to delay building it but I want it to be ready". The move is planned here and built later; itd-2610091010348892 ships without it. +- 2026-10-14: asked what abcdev.app shows once abcd's record builds onto its own GitHub Pages (options: the same site on the new host, forward to github.io, park the domain), the product thinker chose "Forward to github.io": abcdev.app forwards every address to the same page of abcd's GitHub Pages site, by one forwarding rule kept at the domain. +- 2026-10-15: the product thinker ruled that a site renders only what abcd writes to a repository anyway (itd-2610091010348892, Decisions of 2026-10-15); that docs are read on GitHub by default, while abcd's own docs are rendered in high quality on abcdev.app until a community site arrives; and that abcdev.app is abcd's docs site, while abcdesign.app is where a team edits its brief, for any abcd-managed repository. This replaces the 2026-10-14 "Forward to github.io" answer for abcdev.app as a whole: abcdev.app serves abcd's docs, and only its record addresses forward to abcd's GitHub Pages site. The press release, the criteria on abcdev.app and the docs step are rewritten to say so; the docs step leaves the project workflow, since no project's site renders docs. + +## Grounds + +- pursued: abcd's site moves onto the route it offers every project because the same workflow then builds both, so each abcd release exercises that route; shown wrong if abcd's site needs a step no other project could declare, or if links shared before the move stop landing diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index 1cca9a0b1..53301d474 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -52,6 +52,7 @@ ".abcd/development/brief/04-surfaces/29-report.md", ".abcd/development/brief/04-surfaces/37-reserve.md", ".abcd/development/brief/04-surfaces/32-scribe.md", + ".abcd/development/brief/04-surfaces/37-session.md", ".abcd/development/brief/04-surfaces/22-site.md", ".abcd/development/brief/04-surfaces/39-status.md", ".abcd/development/brief/04-surfaces/21-update.md", @@ -96,6 +97,7 @@ "report", "reserve", "scribe", + "session", "site", "status", "update" diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index d3c575abd..e432c888f 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -273,6 +273,13 @@ "required": false, "hidden": false }, + { + "name": "live-sessions", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, { "name": "only-waiting", "shorthand": "", @@ -3309,12 +3316,26 @@ "hidden": false, "sentence": "Gate the built website, rendering it first when absent: Writes only inside the output directory; refuses a site failing any gate with exit 1.", "flags": [ + { + "name": "base-path", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, { "name": "out", "shorthand": "", "type": "string", "required": false, "hidden": false + }, + { + "name": "project", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false } ] }, @@ -3678,6 +3699,161 @@ } ] }, + { + "path": "abcd session", + "hidden": false, + "group": "set-up", + "block": "people", + "sentence": "Set up live brief sessions, start one here, or run, deploy or write the team's relay and page: Writes nothing bare; refuses an unknown sub-verb.", + "flags": [] + }, + { + "path": "abcd session blueprint", + "hidden": false, + "sentence": "Write the team relay's Render blueprint and Dockerfile, pinned to a release: Writes render.yaml and .abcd/relay/; refuses an edited file or no GitHub origin.", + "flags": [ + { + "name": "origin", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "relay-origin", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "release", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd session page", + "hidden": false, + "sentence": "Write the brief session's static page and its editor bundle into a directory: Writes only the page's six files; refuses a directory holding anything else.", + "flags": [ + { + "name": "base-path", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "out", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd session serve", + "hidden": false, + "sentence": "Run the brief session relay in the foreground, on loopback by default: Writes nothing; refuses a bad --listen, or no repository to serve beyond loopback.", + "flags": [ + { + "name": "behind-proxy", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "client-id", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "listen", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "origin", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "owner", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "repo", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd session setup", + "hidden": false, + "sentence": "Set up live brief sessions with the team's own GitHub sign-in app: Writes its client id, the answer and the blueprint; refuses no GitHub origin or no answer.", + "flags": [ + { + "name": "relay-origin", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "release", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "where", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd session start", + "hidden": false, + "sentence": "Start a live brief session on this computer, over your tailnet: Writes only an answer it asks; refuses no recorded GitHub App, no Tailscale or no GitHub origin.", + "flags": [ + { + "name": "client-id", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "port", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd site", "hidden": false, @@ -3699,6 +3875,13 @@ "hidden": false, "sentence": "Render the website into the output directory: Writes there, plus a missing label into site-src/ui.json; refuses a non-empty directory it did not write.", "flags": [ + { + "name": "base-path", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, { "name": "commit", "shorthand": "", @@ -3727,6 +3910,13 @@ "required": false, "hidden": false }, + { + "name": "project", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, { "name": "version", "shorthand": "", @@ -3762,6 +3952,20 @@ "required": false, "hidden": false }, + { + "name": "provider", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "replace-existing", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, { "name": "yes", "shorthand": "", diff --git a/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md b/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md index 6e3c6f417..7531ea424 100644 --- a/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md +++ b/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md @@ -2444,6 +2444,16 @@ The planning interview added two criteria from the product thinker's pushback on A third added criterion, that an announcement names nobody, was struck when the product thinker returned to GitHub usernames. +## 2026-10-16: Emacs keys in the brief editor + +| part | type | home | +|---|---|---| +| a per-person option that switches the brief editor to Emacs keys, off by default, remembered per browser | capability | intent itd-2610161904178040 (draft), blocked by the live brief session intent itd-2610091016262335 | +| page commands as Emacs-style chords | capability | not filed: the product thinker chose the editor option only | +| the editor extension the option needs | plumbing | the spec, at planning; a new vendored dependency needs sign-off first | + +Verdict: FILE-AS-IS. The scope question (editor option per person, editor always on, editor plus page commands) was put to the product thinker, who chose the per-person editor option, so the routing survived as proposed. + ## 2026-10-16: the agents abcd launches on its own work inside an OS sandbox (product thinker, routing to be read) | part | type | home | diff --git a/.abcd/development/research/references.csl.json b/.abcd/development/research/references.csl.json index cb1ad6fd1..3e1be28c6 100644 --- a/.abcd/development/research/references.csl.json +++ b/.abcd/development/research/references.csl.json @@ -252,6 +252,22 @@ "issued": {"date-parts": [[2011, 11, 15]]}, "URL": "https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions" }, + { + "id": "parecki2026browserapps", + "type": "report", + "author": [ + {"family": "Parecki", "given": "Aaron"}, + {"family": "De Ryck", "given": "Philippe"}, + {"family": "Waite", "given": "David"} + ], + "title": "OAuth 2.0 for Browser-Based Applications", + "publisher": "IETF OAuth Working Group", + "genre": "Internet-Draft", + "number": "draft-ietf-oauth-browser-based-apps-27", + "note": "Since published as RFC 10017 (BCP 212)", + "issued": {"date-parts": [[2026, 8]]}, + "URL": "https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/27/" + }, { "id": "pearce2022asleep", "type": "paper-conference", diff --git a/.abcd/development/specs/open/spc-2610141709430146-the-product-thinker-and-the-technical.md b/.abcd/development/specs/open/spc-2610141709430146-the-product-thinker-and-the-technical.md index a86a8b51d..7d6d4f255 100644 --- a/.abcd/development/specs/open/spc-2610141709430146-the-product-thinker-and-the-technical.md +++ b/.abcd/development/specs/open/spc-2610141709430146-the-product-thinker-and-the-technical.md @@ -9,13 +9,180 @@ production_mode: hand-written ## Summary -_Draft: describe what spc-2610141709430146 delivers for itd-2610091016262335 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ +A team opens its brief during a call and edits it together live: on abcdesign.app///brief/, or, for a sensitive project, on the host's own computer over the team's tailnet. The session runs on a relay the team owns, and changes go back to GitHub as issues or one pull request, never through anything abcd runs. The page never shows or asks for a relay address and never asks for a token (adr-2610161400542916). It opens onto the brief, press release first, in plain words for product thinkers and domain experts. + +**One relay, two routes.** The relay is a verb in the abcd binary. `abcd session serve` runs it for the team's cloud relay, and `abcd session start` runs it for a local session (see Local session). It listens on loopback by default, and it serves these routes: +- the session's WebSocket at `/session` +- the web-flow sign-in's callback at `/auth/callback`, the callback URL the team's GitHub App lists, and its exchange at `/auth/token`, offered when the relay holds the GitHub App's client id and secret +- the device-flow sign-in at `/auth/device`, `/auth/device/token` and `/auth/device/cancel`, offered only by a local session's relay, which serves its own page and holds the client id alone (see Sign-in) +- the GitHub App's public client id at `/config`, with `app_type` always `github-app` when a client id is set, since the relay takes no other kind of sign-in app (see Sign-in); `flow`, `web` or `device`; and, for the web flow, an authorize URL that carries no scope, which a GitHub App does not use ("GitHub Apps do not request scopes during authorization", https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). `serve` refuses to start with `ABCD_SESSION_GITHUB_APP_TYPE`, which once chose an OAuth app, set to anything but `github-app`. +- a health check at `/healthz` +- for a local session only, the page itself (see Local session) + +A relay serves sessions only for the repositories it is given: each `--repo owner/name`, every repository of each `--owner`, and each entry of `ABCD_SESSION_REPOS`, a comma list of either. Nobody verifies against, or hosts, any other repository, so a stranger signed in to GitHub cannot hold a team's relay with a repository of their own. A relay listening beyond loopback refuses to start without one; on loopback it serves the checkout's origin repository, or any repository outside a GitHub checkout. + +The relay reaches the team by one of two routes: +- **The host's own machine.** `abcd session start` runs the relay and serves the page from it. The host exposes it with `tailscale serve`, which reaches teammates on the tailnet and gives the browser the https address it needs. Funnel is never used, because it would put the person's computer on the internet. Someone outside the tailnet reaches a local session only when the host shares the computer with them through Tailscale, and joins as a guest (adr-2610032150581128, amendments of 2026-10-14 and 2026-10-16). +- **The team's own relay.** `abcd session blueprint` writes a Render Blueprint and a Dockerfile under `.abcd/relay/`. The Dockerfile fetches the released abcd binary for linux/amd64 and checks it against the release's checksum before running the same verb. The verb refuses a release older than the first to carry `abcd session` (a constant the release cut sets, now v0.17.0; the running abcd's own release is always taken), since that container would exit at once. One deploy serves all of the team's sessions. The Blueprint sets `ABCD_SESSION_REPOS` to the checkout's origin repository, a plain value, and `ABCD_SESSION_ORIGINS` to the page origins the relay admits: `https://abcdesign.app`, or the https origins `blueprint --origin` names, each followed by the page's base path when the page is served below one (`https:////`, as `page --base-path` writes it). `serve` reads `ABCD_SESSION_ORIGINS`, a comma list, whenever no `--origin` is given. Given `--relay-origin`, the verb also writes `.abcd/relay/relays.json`, `{"relays": [""]}` with a stamp key the page ignores, which the page reads from the repository as its trust anchor for relays. + +The relay holds: +- each session's Yjs updates, as opaque bytes, in memory only. A session's log is dropped when the host closes the session, or when nobody has been connected for the idle limit. +- on a cloud relay, the team's GitHub sign-in app secret, read from the environment by name. The secret is never logged and never sent to a browser. A local session's relay holds no secret. +- each device sign-in under way: GitHub's device code, under a random handle, in memory only, at most 64 at once and four from one client address, each dropped when GitHub's code expires or the sign-in ends. The device code never reaches a browser. + +The relay never parses the brief and never writes to disk. + +**The wire.** A JSON control message is a text frame. A Yjs update or awareness message is a binary frame with a one-byte tag: +- tag 0, an update, sent by a team member's page; +- tag 1, an awareness message, sent by anyone; +- tag 2, an attributed update, sent only by the relay and refused from a page: the tag, the sender's participant number as four big-endian bytes (the N of its roster id `pN`), then the update's bytes exactly as the member sent them after its tag 0. Every update is logged and sent on in this form, live and in the replay. The roster's `authors` map names the login of each participant number the log carries, including members who have left, so a page checks that the Yjs client ids inside an update are credited to its sender's login. The relay still never parses the update. +- tag 3, an attributed awareness message, sent only by the relay and refused from a page: the tag, the sender's participant number as four big-endian bytes, then the awareness message's bytes exactly as the participant sent them after its tag 1, mirroring tag 2. Every awareness message is sent on in this form, and none is logged. A page keeps an awareness state only when its client id is tied to that sender: credited to a member's login, or, for a guest, who has no client ids credited, the one client id the guest's first awareness message named while the guest is in the roster. It labels the cursor from the roster, a member by their login and a guest by the name the relay stamped, never by the name the message claims. + +The roster also names the login that opened the session (`host`), while the host is away too. The shared `meta` map (`base_snapshot`, `base_sha`, `branch`, `repo` and `mode`) counts a value only when the Yjs item that holds it was written by a client id credited to that login: the first four as the host's page seeded them, any later write void, and `mode` as the host last set it. A page warns when it sees a void write, and reads the base, the pull request's target and the commit's expected head through that one rule. +A page that refuses an update (one holding a client id not credited to its sender) also sets aside, with a note, whatever Yjs then holds pending on that update's clocks: every page refuses the same update, so nothing could ever supply them. + +These control messages exist: +- `host`: a team member opens a session for owner/repo. The relay answers with a six-character join code from rascals' 31-symbol alphabet. The code works while the host keeps it open, and the host can rotate it. +- `join`: anyone presents the code. Failed joins are rate-limited per address: an IPv4 address, or an IPv6 /48, or one tailnet address, 20 wrong codes in ten minutes each. Past 600 wrong codes from everywhere within ten minutes, joining by code pauses for everybody for ten minutes, as the rascals server does: every code tried meanwhile gets the same `paused` answer, right or wrong, and counts for its own address alone. A rejoin token and a verified team member are not held up. + + **Accepted tradeoff (security review 2, finding 9).** About thirty addresses, each spending its own 20 wrong codes, can pause joining by code for everybody for ten minutes, and they can repeat that. The pause is what keeps a guesser with many addresses from searching the 887 million codes, so it stays. What it costs is bounded: only a new guest joining by code waits, and the session itself goes on; a participant comes back by their rejoin token, and a team member is exempt, since signing in admits them whatever the pause. Exempting members does not help a guesser: a member's wrong codes do not count towards the pause, and a member can open the session directly. The budget was raised from 200 to 600 so the pause takes about thirty addresses rather than ten. The forwarded address is trusted only behind a declared proxy, or, on a relay bound to loopback alone, as Tailscale Serve forwards it: one tailnet address, on a connection from loopback. Behind a proxy it is the rightmost `X-Forwarded-For` entry, one trusted hop: Render's proxy appends to the header, so the client controls the leftmost entry (https://render.com/articles/host-pocketbase-on-render), and Render's advice is to trust one hop (https://render.com/articles/how-render-handles-ddos-attacks). On a loopback relay a process on the host could forge a tailnet address in that header; that is accepted, since such a process already holds the host's machine. +- `verify`: a participant presents the GitHub token their sign-in gave them, for a repository the relay serves. The relay takes only a GitHub App user access token, whose documented prefix is `ghu_` (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#githubs-token-formats), and refuses any other before asking GitHub, so a pasted personal access token is never accepted. It asks GitHub for the login, and for the repository's `permissions.push`. A participant with write access or above becomes a team member, and the relay drops the token straight after the check. +- `rejoin`: a participant whose connection dropped presents the rejoin token the relay gave them with `hosted` or `joined`: 128 random bits, held in memory with the participant as a digest. It restores the same participant (role, name and verified login) without the code, so rotating the code shuts nobody out; closing, purging or expiring the session voids it. The role restored is the one the relay recorded, not asked of GitHub again. **Decision (facilitator, on security review 2, finding 8):** a member whose write access GitHub has since revoked keeps their role at most until the session ends or expires; a session expires 12 hours after it was opened, however busy, and ends for everyone as a host's close does, with `closed {reason: "expired"}`. Failed rejoins count with failed joins. The page keeps the token in memory and in the tab's session storage, so a reload rejoins too. + +The relay replays the session's update log to every newcomer, as one item on the connection's send queue that the writer sends entry by entry as the connection takes them, so a log longer than the queue never drops a newcomer. A replay is bounded to a minute, and a close the relay asks for during one stops it at the next entry and is written at once. A connection the relay closes leaves its session at once but stays counted in the connection caps until its socket has ended, which the transport ensures within 5 seconds of the close, so a close waiting behind a replay frees no place for another socket (final review A). It then: +- forwards Yjs updates only from team members. A guest's binary updates are refused, which is how guests stay read-only on the text. +- forwards a guest's `suggest` message to the team members, stamped with the guest's display name. +- broadcasts the roster of verified identities. + +Limits bound all of this: message size (256 KiB), an awareness message's size (8 KiB); log bytes, each entry charged 64 bytes beyond its own; log entries (50,000); participants per session; sessions per relay, an expired session holding none while it waits for the sweep (final review G); idle time; a session's life (12 hours); connections (256 per relay and 16 per address, past which the upgrade is refused with 503); and 30 seconds for a connection to host, join or rejoin before it is closed, its send queue held to 64 items until then. Each connection's send queue holds at most 1,024 items and 8 MiB of frames; a log replay is charged no bytes, its entries being the session's own. A connection past either is dropped at once, its queue with it, so a page that stops reading holds nothing more (security review 2, finding 1). A guest's suggestions are bounded in size (70 KiB each), per participant on their seat, so a rejoin does not reset them (100 suggestions and 256 KiB; final review B), and in total (1 MiB per session, the backstop for a guest who joins anew by code), since a member's page records each in the document and so in the log (finding 2). + +Rates bound it too: each connection's updates (60 a second, in bursts of 200), awareness messages (20 a second, in bursts of 40, and 32 KiB a second, in bursts of 64 KiB, below the session's rate; final review C) and suggestions (1 a second, in bursts of 5), each session's awareness bytes from every participant together (128 KiB a second, in bursts of 512 KiB), its sign-in attempts (5), the sessions one address opens (3 in 10 minutes), and the sign-in exchanges at `/auth/token` and device sign-ins started at `/auth/device` from one address (10 in 10 minutes together, keyed as wrong codes are, then 429 with `Retry-After` before GitHub is asked; finding 10). A device sign-in's poll asks GitHub no faster than GitHub's interval, whatever the page sends. A message over its rate is dropped with a `rate-limited` error naming its `kind` (`update`, `awareness`, `host` or `suggest`), and the connection stays open. An update or awareness frame over its connection's rate is judged before the relay's lock is taken and answered once a second at most, the drops between silent (final review E). Every refusal of a `suggest` names kind `suggest`, and a suggest dropped past the message budget below is answered too, once a second, so a guest's page always learns its suggestion went; the page also gives one up after 15 seconds without an answer (final review F). On a drop: the page resends recent updates, tells a guest to send a dropped suggestion again, and answers a dropped cursor with nothing. Every control message, and every frame the relay refuses, also spends one budget per connection (50 a second, in bursts of 100): past it, the message is dropped unanswered before the relay's lock is taken, and a connection refused 1,000 times is closed (finding 7). An accepted update or awareness message spends nothing of it, and a rate limit's own refusal does not count as one. Origins are checked against an allowlist: abcdesign.app and loopback pages by default, or exactly the origins `--origin` names, or, without `--origin`, those `ABCD_SESSION_ORIGINS` names, which replace them. An entry may go on to name the page's base path; a browser's `Origin` carries no path, so for everything but the sign-in's callback the entry admits its origin alone. + +**Sign-in.** The relay offers one of two sign-ins through the team's GitHub App, chosen by how it is set up (adr-2610161400542916): +- **Web flow, on a cloud relay** (client id and secret), offered through a relay the repository's list names, one serving its own page, or, where the list cannot be read, one the person confirmed. The page builds the GitHub authorize URL from the relay's public client id, with PKCE and a state value it keeps in session storage; the state also carries the page's address. Its `redirect_uri` is the relay's own `/auth/callback`, which the team's GitHub App lists as its callback URL. GitHub returns the person there; the relay checks that the page address in the state is on an origin it admits, sits under exactly the base path that origin's entry names (the root for an entry naming none, and for the default origins), and is a session page for a repository it serves, and sends the person back to it with the code, the state and its own origin as `relay`. The page posts the code and the PKCE verifier to that relay's `/auth/token` alone, and refuses a return that names no relay or a different one from the relay the sign-in started with; it never takes the exchange's relay from the invite link or its storage. The token is then presented for verification only to that same relay, never to a relay the invite link named that GitHub did not return the person to. The relay exchanges only a code given for its own callback, adds the secret and does the exchange: the token-mediating backend of RFC 10017 (OAuth 2.0 for Browser-Based Applications). GitHub's web flow still needs the secret for a GitHub App, PKCE or not. **Decision (facilitator, on state-of-the-art grounds, 2026-10-16):** the callback is the relay's, not the page's with the relay in its query. GitHub matches a `redirect_uri` exactly only when the callback's wildcard matching is off, and otherwise by host, port and a path beneath the callback's; its pages say the `redirect_uri` "can't contain any additional parameters" and say nothing about a query in a registered callback (https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url, https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). A relay's own host is compared under either mode, so a code is bound to the relay GitHub returns the person to. **Decision (facilitator, on state-of-the-art grounds, 2026-10-16):** the callback forwards only to the page's own base path, and the base path is carried by the origin entry rather than by a separate relay-wide setting. A static origin can carry other publishers' pages beside the session page, so a path open below the origin would reach pages that are not the session page; the base path is a property of one place the page is published, and one relay may admit the page at abcdesign.app's root and a team's own copy below a project path, which one relay-wide setting cannot express. The flag, the environment variable and `render.yaml` already carry the entries, so no new setting is needed. +- **Device flow, on a local session** (client id alone, and only on the page the relay serves itself; `abcd session serve` never offers it, and the page on any other origin takes the web flow alone). The page posts to `/auth/device`. The relay asks `https://github.com/login/device/code` for a device code, keeps it, and answers with a handle, the user code, GitHub's verification address (only `https://github.com/login/device` is shown) and the timings. The page shows the code in large type, with a button that opens github.com/login/device in a new tab and one that copies the code. It polls `/auth/device/token` with the handle, and tells `/auth/device/cancel` when the person stops the sign-in or leaves the page, which frees its place at once; the relay asks GitHub no faster than its interval, adding five seconds on `slow_down`, and returns the token once the person confirms. GitHub's own words name the app and the account on the confirming page. A device code expires after 15 minutes. GitHub's device endpoints are not called from the browser, because GitHub documents no CORS for them. + +Either way the token stays in the page's memory, and the same `verify` admits it. No token is ever pasted: the page has no field for one, and the relay takes only a GitHub App user access token. A relay with no client id offers no sign-in. The page then says, in plain words, that sign-in is not set up for this project yet and to ask the technical facilitator. A guest can still join. + +**Why the cloud keeps the web flow** (state-of-the-art grounds, adr-2610161400542916). The device flow needs no secret ("The `client_secret` is not needed for the device flow", https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps), but: +- it is open to remote phishing (RFC 8628 §5.4) with the app's public client id; +- GitHub limits it to 50 code submissions an hour per app; +- it sends every member to a second tab to type a code. + +The secret already sits on the team's own relay. On the host's own computer, where the product thinker ruled out a secret, the device flow is the one that fits. **Tailscale Serve's identity headers are not read:** a Tailscale login says nothing about write access, and a local program could write them (adr-2610032150581128). + +**Where the page finds its relay.** The page never shows a relay address, never asks for one, and never asks the person for technical input. In order: +1. A page the relay serves itself (a local session) carries ``, and takes its own origin as the relay. The page and the relay are one server, so the relay list does not apply. +2. On abcdesign.app, the page reads the repository's relay list (below). The relay is the one the invite link's fragment names, when the list names it; with no relay in the link, the first relay the list names. +3. Where the list cannot be read without signing in (a private repository), the relay is the one the link names, or one the person confirmed earlier in this tab for this repository. Before a name or suggestion reaches it, the page asks one plain question, naming the repository and no address: "This page can't check this invite against owner/repo's settings. Only continue if someone on the owner/repo team sent you this link." The answer admits the relay for a guest's join and for the web sign-in, so the members of a private repository sign in as they do for a public one: GitHub sends the code only to the callback the team's app lists, and the page takes the exchange's relay only from that return. The confirmation is kept in the tab's session storage, for that repository, and lapses after twelve hours, a session's longest life, so a restored tab does not carry it on. +4. With no relay from any of these, the page says that live sessions are not set up for this project yet, and to ask the technical facilitator. It offers nothing to type. + +A link's fragment can name any relay, so the repository is the page's trust anchor for relays (security review F1). Before any sign-in, token or display name reaches a relay, the page reads the repository's committed `.abcd/relay/relays.json` (`{"relays": ["https://…"]}`, https origins, other keys ignored) from `api.github.com` on the default branch, without a token for a public repository, and refuses a relay the file does not name: no `/config`, no sign-in, no `verify`, no join and no connection. The page then says the invite link does not lead to this project's own session and to ask the host for a new one, showing no address. Only GitHub's 404 means there is no list the page can see. A 403, a 429, a 5xx or a network error means the list could not be checked: the relay is refused, with "the page could not reach GitHub to check this project's settings; try again in a moment" and no confirmation offered, and a Try again button reads the list again (security review 2, finding 5). Once a token exists, the page asks `api.github.com` alone for the file again, with the token; a file that lists relays without this one refuses `verify` and forgets the confirmation. + +The invite link carries the relay in its fragment only when the guest's page could not find the same one itself: never for a local session, and on abcdesign.app only when the relay is not the first the list names. The invite card shows the QR code, the join code and a Copy invite link button; the link itself is not printed on the page. + +Sign-in goes through a GitHub App only. **Decision (facilitator, on security review 2, finding 3, on state-of-the-art grounds):** a link can open the page for a stranger's repository, whose own `relays.json` names the stranger's relay, so the list alone cannot stop a relay collecting tokens; what bounds the harm is the token. A GitHub App's user token reaches only the repositories the app is installed on, within its fine-grained permissions (Contents and Pull requests read and write, Metadata read), and GitHub recommends Apps over OAuth apps (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps), while an OAuth app's `scope=repo` reaches every repository the person can. So the authorize URL never carries a scope, the oauth-app choice is gone, and a relay whose `/config` says anything other than `github-app` gets no sign-in button. The page names the repository prominently beside the sign-in ("You are signing in to work on owner/repo"), and the confirmation for a relay the list cannot speak for opens with the repository's name. + +**Frames.** A static host such as GitHub Pages cannot send `frame-ancestors` or `X-Frame-Options`, and a `` Content-Security-Policy cannot carry `frame-ancestors`. That is accepted (security review 2, finding 11b), with mitigations: the page refuses to run in a frame (`window.top !== window.self`) and shows only a link that opens it in its own tab, so no relay confirmation, sign-in or session is ever drawn where another page could overlay it; the sign-in itself happens on github.com, which refuses to be framed. + +**The page.** abcd generates the page itself. `abcd session page --out ` writes plain files from assets embedded in the binary: +- one HTML file, served for every path through GitHub Pages' 404 fallback, which is how `///brief/` resolves on a static host (a local session's relay serves the same files itself; see Local session) +- the page script +- the stylesheet +- `collab.js`, a vendored bundle + +`collab.js` holds Yjs 13.6.33, y-codemirror.next 0.3.6, y-protocols 1.0.7, CodeMirror 6 with Markdown, and qrcode-generator 2.0.4, all MIT. It was built outside the repository with esbuild 0.28.2. Every package version is at least two weeks old. Its provenance (each package, version and npm integrity hash, the entry module and the bundle's SHA-256) is committed beside it, and a Go test fails if the bytes drift. No Node toolchain enters the repository, consistent with adr-47. + +The host's page loads the brief through the GitHub API with the host's token. It records the default branch's commit as the session's base and seeds one Y.Text per brief file exactly once, before any invite. It then shows a QR code that encodes the invite link: the page URL with the code, and the relay only where the link needs it, in the URL fragment, so neither reaches GitHub's servers. + +**The start screen** is in plain words, for product thinkers and domain experts. It names the project, then offers two cards side by side: +- with a code in the link, an invitation: "Sign in with GitHub" for the project's team, who edit directly, and a name field with "Join as a guest" for everyone else, who suggest changes; +- without a code, "Sign in with GitHub" for the team, then the choice of how the work goes back and "Start the session"; and a "Have a session code?" card for a code given by voice. + +**The page's look** (the product thinker's ruling, 2026-10-16). The page has two designs over the same elements and logic. Guided is the default: large cards, numbered areas, "What happens next" (before the session on each step the page can go on from and when sessions are not set up, never while it is checking or cannot go on; live, for guests only), arrival cards whose main buttons share a line, one control height per size and a 4 px spacing scale. Shared is the alternative: a live team bar over the document with an icon-only Invite at its end, an icon-only Send to GitHub between the team bar and Suggestions that widens to its label on hover or keyboard focus, Suggestions as the only button on the right, mirroring Documents; labels fade and fold to icons as the window narrows, least important first, with nothing cut, wrapped or overlapping; no "What happens next"; and a Close X at the top right of every overlay. Ctrl+Shift+[ and Ctrl+Shift+] (Ctrl+{ and Ctrl+}, read from the key's place) switch between the designs on every platform, the Mac included, never Cmd, with a short toast naming the design, and plain Ctrl+[ and Ctrl+] stay the editor's indent and outdent; on Windows and Linux the page takes Ctrl+Shift+[ and Ctrl+Shift+] from the editor's fold and unfold, and as the page shows no fold arrows a section cannot be folded there (a Mac folds with Cmd+Alt+[ and Cmd+Alt+]); the choice is remembered per viewer in local storage, every access wrapped so blocked storage falls back to Guided. Both designs toggle the documents with Alt+Shift+D and the right panel with Alt+Shift+S, named in the tooltips, each toggle with one name and aria-pressed; a hidden sidebar gives its space to the brief and the state survives changes of screen and design; a phone, where the sidebars become sheets, keeps its own state apart from a wider window's, and so does the right panel at the slide-over widths; Escape closes the topmost overlay that can be seen, and is the editor's inside it and an input method's while it composes; a dialog takes focus on opening and returns it to its opener, and focus on a control a layout change hides moves to its counterpart. Both work at desktop, tablet and phone widths, in light and dark (`prefers-color-scheme`), with `prefers-reduced-motion`, keyboard reach and visible focus, on the system-ui font stack with inline SVG icons and no platform-specific chrome. The accent is cobalt, one token: #2457d6, and #7aa2ff in dark. + +The words "relay", "token" and "personal access token" appear nowhere on the page. Once a session is joined or started, the page opens the brief's press release (the brief file whose name holds `press-release`, or else the first file). + +Participants: +- see each other's edits and cursors live (awareness) +- if they are guests, propose a replacement for a selected passage; a team member applies or declines it, and the record names the approver. A member's page records only a suggestion's validated fields, each bounded: a path among the brief files the session started with; relative positions cleaned and at most 256 bytes as JSON; the passage's context at most 200 characters each side; the passage and the replacement at most 64,000 characters; and all of it at most 70 KiB, the relay's cap, which the guest's page checks before sending (security review 2, finding 2). The relay's answer to a suggestion, a `rate-limited` one included, does not say which message it answers, so a guest's page has one suggestion in flight at a time: a refusal or a rate limit clears it and tells the guest it was not sent, and only an accepted one is listed as waiting for a team member. The files a session sends back are confined to the base snapshot read through the host-written settings (`effectiveMeta`), never the member-writable `meta` map as such (finding 6). + +The document has `gc` off, so each change can be credited. Credit comes from the Yjs client ids each member's page registers in the roster. + +**Back to GitHub.** The host picks the route per session. +- **Issues.** Each changed passage becomes a pre-filled issue link, carrying: + - the file path + - the passage before and after + - the base commit + - the credited author, or the approver and the guest + + The base commit is named only when it is 40 hex digits, and a guest's display name, in an issue body or a pull request body, goes in a code span, its fence longer than any run of backticks in it, so it cannot write a link, an image, code or emphasis (security review 2, finding 11a), nor be autolinked: GitHub links a bare URL or domain in a name even with every Markdown character escaped, but nothing inside a code span. A commit's trailer names the guest as plain text. + + Each issue's title starts `Brief:` and its body ends with the fixed line `Sent from an abcdesign.app brief session.` The team's agent session finds a session's issues by that marker, not by a label: a pre-filled label makes GitHub answer 404 to anyone without triage permission, and a label the repository lacks is unreliable. + + The link's length is measured as GitHub receives it, after encoding, against a margin under 8 KB. A longer passage is offered as a pull request instead. +- **One pull request.** The submitter's token calls `createRef`, then `createCommitOnBranch` (the commit's author is the submitter), then `createPullRequest`. The commit message carries a `Co-authored-by:` line for every credited team member. For each approved guest change it carries a `Suggested-by:` line naming the guest, with the `Approved-by:` line beside it. + +Every request in a session goes to the team's relay, to GitHub, or to the static page's host. None reaches a server abcd runs. + +**Local session.** `abcd session start` is the host's one command for a session on their own computer, for a project whose brief should not leave the team's tailnet: +- It reads this checkout's origin repository on github.com, refusing a checkout without one, and serves sessions for that repository only. +- It reads the GitHub App's public client id from `--client-id`, from `ABCD_SESSION_GITHUB_CLIENT_ID`, or from `.abcd/relay/github-app.json` (`{"client_id": "…"}`, which repository setup writes). Without one, it refuses and says that sign-in is not set up for this project. It never reads or holds a secret, so it offers the device flow. +- It asks Tailscale, through the read-only adapter, for this computer's tailnet name (`tailscale status`), and refuses when Tailscale is not running. It changes nothing in Tailscale. +- It listens on loopback (`127.0.0.1:8787`, or `--port`) and admits exactly `https://` and its own loopback address, as page origins and as `Host` headers. Any other host gets 421 before anything is read. That is the DNS-rebinding guard adr-2610032150581128 asks for. +- It serves the page's embedded files from a fixed route table: `/session.js`, `/session.css`, `/vendor/collab.js`, `/vendor/LICENSES.txt`, and the HTML for `///brief/` of the repository it serves, carrying the `abcd-session-relay` marker. Nothing is read from a requested path. The page's responses carry `frame-ancestors 'none'` and `X-Frame-Options: DENY`. +- It prints the one Tailscale step (`tailscale serve --bg `, which Tailscale keeps across restarts), the link to open (`https://///brief/`), and that people outside the tailnet can join as guests once the host shares the computer with them in Tailscale. It runs until interrupted, and every session ends with it. + +Every security property of `serve` holds for it unchanged: the limits, the rejoin tokens, suggestion attribution, the origin checks and the `ghu_`-only `verify`. + +**Setup (repository setup, a later lane).** Setting up live sessions belongs to repository setup (`abcd ahoy`), built by a separate lane after this one. That lane builds: +1. **The first question:** "Set up live brief sessions?" with Yes, No and Later. Later is asked again at the next setup run, and when someone runs `abcd session start` or `blueprint` on a project not yet set up. No is recorded and never asked again, but the setup step stays runnable by hand. +2. **The second question,** after a Yes: "Where may sessions run?" with Local, Cloud and Both. +3. **The team's GitHub App,** created through GitHub's App manifest flow. That is a POST form to `https://github.com/settings/apps/new`, or `https://github.com/organizations//settings/apps/new` for an organisation, carrying a pre-filled manifest: + - the app's name; + - the callback URL, for the cloud: the relay's own address followed by `/auth/callback`, when the relay's address is already known. An app already recorded is kept, and setup says to remove the session page's own address (`https://abcdesign.app///brief/`) from its callback URLs if it is listed, since an earlier setup listed it and the sign-in no longer returns there. Where the cloud is allowed, setup names this callback URL to list on the app's settings page, with wildcard matching off, once the relay's address is recorded (step 6), and before then names its shape; + - permissions: Contents and Pull requests read and write, Metadata read; + - no webhook; + - `public: false`. + + The conversion (`POST /app-manifests/{code}/conversions`, within the hour) returns the client id, the secret and a private key. abcd keeps only the client id, in `.abcd/relay/github-app.json`. The private key, the webhook secret and the client secret are discarded unwritten and never shown: a secret printed in a terminal can end up in a transcript. For the cloud, the person generates a client secret on the app's settings page and pastes it straight into Render's form (step 5), so no secret passes through abcd. That is one step more than the product thinker's two, and the lane says so when it asks. The person then installs the app on the repository: one more click, on the page GitHub opens. +4. **Device flow for Local.** A manifest cannot switch on the device flow, so for Local or Both the person ticks "Enable Device Flow" in the app's settings, on the page abcd opens. abcd says why the tick is needed: anyone holding the public client id can start a device sign-in. It is left off for Cloud alone. +5. **Deploying for Cloud.** The relay is deployed with Render's Deploy button (`https://render.com/deploy?repo=`). Its Blueprint declares the secret as `sync: false`, so Render's own form asks for it at creation, and abcd never sees it again; the client id, public, is written into it as a plain value. **Settled (facilitator, on state-of-the-art grounds, 2026-10-16):** the button takes only the repository's URL, with an optional `/tree/`, and Render documents no parameter naming another Blueprint path (https://render.com/docs/deploy-to-render); Render reads the Blueprint at the repository's root, and only its New Blueprint form takes a custom Blueprint Path (https://render.com/docs/infrastructure-as-code). So `blueprint` writes `render.yaml` at the root, the Dockerfile staying under `.abcd/relay/`. A root `render.yaml` the team wrote is never touched: the Blueprint then goes to `.abcd/relay/render.yaml`, deployed through the New Blueprint form with that Blueprint Path. +6. **The relay's address,** recorded by abcd itself once Render has given it (`blueprint --relay-origin`, which writes `.abcd/relay/relays.json`). abcd reads it from Render's answer, or the person pastes it into the terminal: never into the page. `blueprint --relay-origin` and `setup --relay-origin` then print the exact callback URL to list on the app, the relay's origin followed by `/auth/callback`. + +abcd never runs a server that holds project content or a key. A local session's GitHub App is the team's own, registered once at setup as above: the product thinker ruled on 2026-10-16 that each team registers its own app and abcd owns no shared one (itd-2610091016262335's Decisions). + +The setup step is `abcd session setup`, runnable by hand whatever the first question's answer. The answer is `sessions.live` in `.abcd/config.json` (`local`, `cloud`, `both`, or `none` for a no), which `abcd ahoy install --live-sessions` also sets. `setup` runs the manifest flow through a page on this computer's loopback address: the page posts the manifest to GitHub, and GitHub's redirect returns to that address with the code, which `setup` converts, reading the client id, the app's slug and its owner and nothing else. + +abcdesign.app is published from a repository of its own, intentdriven/abcdesign, created on 2026-10-15 at the product thinker's go. abcd's own GitHub Pages site is planned to become abcd's record (itd-2610142145236613, adr-2610142147242544), and a repository has one Pages site. That repository holds no code: its one workflow downloads the latest abcd release daily or on demand, verifies its checksum and build provenance, runs `abcd session page`, and deploys to its GitHub Pages under the abcdesign.app domain, using only the run's own token. A release without `abcd session page` publishes nothing. Its main branch takes changes only through a pull request, its Actions are limited to GitHub's own, and its workflow token is read-only by default. Pointing abcdesign.app's DNS at GitHub Pages and verifying the domain for the organisation are the product thinker's steps, with a token scoped to that zone. ## Footprint -- packages: -- tests: +- packages: internal/core/session (new), internal/surface/session (new), internal/surface/cli, commands/session.md, docs/, internal/core/ahoy (step 6) +- tests: internal/core/session, internal/surface/session, internal/surface/cli, evals/ smoke (smoke_session_test.go runs `abcd session page --out`) ## Steps -_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. `, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ +1. The relay: session rooms, join codes, roles, update log, suggestions, sign-in exchange and limits, behind `abcd session serve`, with the WebSocket library github.com/coder/websocket v1.8.15 (ISC, no dependencies) + - packages: internal/core/session, internal/surface/session, internal/surface/cli, commands/session.md + - tests: internal/core/session, internal/surface/session + - landed: cf20d3ded +2. The relay blueprint and the Tailscale route: `abcd session blueprint` writes `.abcd/relay/render.yaml` and `.abcd/relay/Dockerfile`, pinned to a released binary and its checksum + - packages: internal/core/session, internal/surface/cli, docs/ + - tests: internal/core/session, internal/surface/cli + - landed: a2ab10f7a +3. The page: `abcd session page --out` emits the static page and the vendored, provenance-pinned editor bundle; joining by QR or code, live editing, presence, guest suggestions and approvals, and credit + - packages: internal/surface/session/web, internal/surface/cli + - tests: internal/surface/session + - landed: f835b6f38 +4. Back to GitHub: issue links under the measured length limit, and one pull request with co-author, Suggested-by and Approved-by lines + - packages: internal/surface/session/web + - tests: internal/surface/session + - landed: a30ec9432 +5. The page asks nobody for a relay address or a token (adr-2610161400542916, iss-2610161332024554): the page finds its relay, opens onto the press release in plain words, and signs in through the web flow on a cloud relay or the device flow on a local session; `verify` takes only a GitHub App user access token; `abcd session start` runs a local session that serves its own page + - packages: internal/surface/session, internal/surface/session/web, internal/surface/cli, commands/session.md, docs/ + - tests: internal/surface/session, internal/surface/session/web, internal/surface/cli + - landed: 9baf9b93c +6. Setting up live sessions at repository setup: the two questions, the GitHub App manifest, the device-flow switch for local sessions, Render's Deploy button and the recorded relay address (a later lane) + - packages: internal/core/ahoy, internal/surface/cli, commands/ahoy.md, docs/ + - tests: internal/core/ahoy, internal/surface/cli diff --git a/.abcd/development/specs/open/spc-2610141709461788-anyone-who-opens-abcdev-app-finds-the.md b/.abcd/development/specs/open/spc-2610141709461788-anyone-who-opens-abcdev-app-finds-the.md index f51651067..e94cbdcaf 100644 --- a/.abcd/development/specs/open/spc-2610141709461788-anyone-who-opens-abcdev-app-finds-the.md +++ b/.abcd/development/specs/open/spc-2610141709461788-anyone-who-opens-abcdev-app-finds-the.md @@ -9,13 +9,177 @@ production_mode: hand-written ## Summary -_Draft: describe what spc-2610141709461788 delivers for itd-2610091010348892 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ +spc-2610141709461788 delivers itd-2610091010348892: a project that switches it +on at setup has its about page and development record built by its own release +workflow at each release and published on its own GitHub Pages, at +`https://<owner>.github.io/<repo>/` (or the root of a custom domain), with no +secret and nothing run by abcd. It is the existing `abcd site setup` pipeline +with a second hosting provider, not a parallel one: one canonical primitive. +Every word on the site comes from what abcd already writes to the repository, +as the product thinker ruled on 2026-10-15, so the project route has no +composition and no site sources. + +- **The provider.** `internal/adapter/hosting/githubpages` implements the + hosting seam. The repository half holds no secret: the deploy job runs in the + `github-pages` environment with the run's own short-lived token (`contents: + read`, `pages: write`, `id-token: write`) and deploys through + `actions/upload-pages-artifact` v5.0.0 and `actions/deploy-pages` v5.0.1, + pinned to full commits; there is no host configuration file. The host half + speaks the GitHub REST API through `gh` as the person who invoked setup, the + way the forge stage does, so abcd resolves and stores no credential for it: + it reads the repository's visibility and Pages state, creates a Pages site + built by the workflow (`build_type: workflow`), sets the custom domain only + when one was given, and reports the `html_url`. A private repository, a site + published from a branch, a domain routed elsewhere, a live custom domain when + setup was given none, and a site served below another path than the one the + workflow builds for are refused, never moved; setup reads the host before it + asks or writes, so each is refused before anything is written. An existing + workflow-built site is named by its address and build type in the switch + question (or in a question of its own when the composition already holds the + switch), and in the host outcome. The seam gained `Site.Repo`, + `State.Existing` and one method, `Profile` (deploy environment and + permissions, the switch, the public-only rule, whether it publishes a + project site, the base path and the expected address); cloudflare's profile + reproduces its workflow byte for byte. Nothing in the adapter names abcd's + own repository. +- **The switch (criterion 1).** `abcd site setup --provider github-pages` asks + whether to switch the project site on, naming the address, before anything + is written. A yes writes `.abcd/site.json` holding the project site's + settings and nothing else (`"mode": "project"`, a `hosting` block with + `"publish": true`) and adds `.github/workflows/site.yml`. Those two files are + the whole repository half: no composition and nothing under `site-src/`. + This supersedes the coordinator's ruling of 2026-10-14, under which setup + also wrote the site's inputs under `site-src/` as the site's settings. A + settings file is decoded strictly and refuses every composition key + (`identity`, `ui_strings`, `home`, `docs`, `record_pages`, `checks`). A no + writes nothing. `hosting.publish` is refused on a provider without a switch. + `.abcd/config.json` was weighed as the switch's home and not taken: it is + ahoy's layered configuration, rewritten at each install, with a machine + layer that must not switch a repository's site on, and the workflow already + reads the switch from `.abcd/site.json`. +- **Single-source content (criterion 3).** `abcd site build --project` builds + the about page in code from four parts, in order, each only where the + repository holds it: the identity block `.abcd/positioning.json` points at, + when abcd wrote its file; the brief's opening, the first section with text in + `.abcd/development/brief/01-product/01-press-release.md` (the first row of + the brief↔lifeboat contract, `internal/core/lifeboat/mapping.go`); the press + releases of the three newest shipped intents whose audit was met, newest id + first (a timestamped id carries when abcd minted it, and an ordinal id sorts + before every timestamped one; the date an intent entered `shipped/` is git's + and plays no part); and the release list in `CHANGELOG.md`. Anything absent is left out; with + no identity block the page is headed and titled by the repository's name + on GitHub. Setup, and `site build --project` before it writes anything, + refuse a repository holding none of the four: the site would be empty. The + repository's address and name come from the origin remote alone, never from + `.claude-plugin/plugin.json`, a person's file. The record pages render as on + every site, from `.abcd/record-lint.json` or, where a managed repository + carries none, from abcd's standard layout under `.abcd/development/` (the + issue ledger only where the settings opt in). The references page (it + quotes `ACKNOWLEDGEMENTS.md`) and every picture (pictures live under + `docs/`; asking for them is iss-2610150529485165) are left out. A project + site also carries no git-history details, as the product thinker ruled, and + the build never reads git history (no log, shortlog or blame): no + contributors page, no timeline, none of the health page's authorship checks, + and no `authorship`, `history` or per-record `dates` in `record.json`; a + record is dated by its own frontmatter alone. abcd's own composed site keeps + all of them, and every page and `record.json` are byte-identical before and + after (3097 files at `/` and at `--base-path /abcd/`). The one file that + changes is the graph script `record.js`: a record exported without git dates + shows its own date as that date and links its history undated, and a record + with git dates is shown exactly as before. Whether a project site should show any of it is to be + revisited. The workflow builds with abcd's latest + release, so a project site cannot build until a release carries + `--project`; setup says so in a note on every project run. + The interface words, stylesheet, scripts and headers policy are the binary's + (`setupsrc/`), so the repository carries no site sources. A build or gate + whose `--project` and settings disagree is refused, both ways. +- **Public repositories only (scope condition 1).** Setup reads the + repository's visibility from the forge first and refuses a private one, or + one whose visibility it cannot read, in one line before it asks or writes. + The workflow holds the same line at each release: the switch step reads the + event's `repository.private` and the deploy job requires it to be `false`, + so a repository made private later publishes nothing. +- **Each release (criterion 2).** The workflow is the existing + render-on-release-then-deploy workflow, with the provider's profile: the + render job builds and gates the site with `--project` and + `--base-path /<repo>/`, and the deploy job hands it to Pages. +- **The base path.** `abcd site build` and `abcd lint site` take + `--base-path` (default `/`, normalised to a leading and trailing slash, plain + path segments only). Every root-absolute URL the build writes goes through + one helper, `sitePath`; the gate resolves the stylesheet against the prefixed + root and a new `base-path` check refuses a root-absolute link outside it. The + default output is byte-identical (golden files unchanged; this repository's + own 3082-file site identical before and after). +- **The managed-repository line (criterion 4).** Every page opens with "This is + the record of an abcd-managed repository", linking to the repository. The + words are a new `managed_record` interface string in the closed `ui.json` + allowlist; a project site reads it from the binary's allowlist. A composed + site with no package manifest takes its address from the origin remote on + github.com, and a project site always does. +- **The provenance gate (criterion 5).** The gate still fails a build on any + word no source or interface string accounts for. With `--project` it also + fails any `data-src` span, on any page, the record's own included, whose + file abcd does not write: `.abcd/**` (but the local tier) and `CHANGELOG.md` + pass, `docs/`, `README.md` and everything else fail. abcd's own composed + site is not a project site and is unchanged: its build is byte-identical + before and after (3091 files at `/` and at `--base-path /abcd/`). +- **No secret (criterion 6).** The workflow reads no `secrets.` value and + interpolates no expression into a shell. +- **Switching off (criterion 7).** With `"publish"` anything but `true` at the + released commit or at the head of the default branch (a missing or malformed + file reads as off), the render job's switch step stops the build and the + deploy job does not run, so the release publishes nothing, and a later push or + dispatch rendering an older tag does not undo it. `abcd site setup` then + touches nothing remote and offers to remove the workflow it added, keeping + one edited by hand. +- **abcd's own repository** is not moved in this change. Its move was this + intent's fourth criterion when the spec was drafted; that criterion has + since moved to its own planned intent, itd-2610142145236613 (with the + proposed adr-2610142147242544), and this intent's seven criteria no longer + include it. The move is more than a composition edit. The workflow setup writes renders the composed site only, and builds + no `/docs/` tree; abcd's composition declares a `docs` block, so its header + links `/docs/`, which abcd's own `site.yml` fills by running MkDocs into + `site/docs` (with `site_url` set to `https://abcdev.app` in `mkdocs.yml`). + Moving abcd onto this route therefore also needs the MkDocs build carried + into the Pages workflow, or the docs link dropped, and the MkDocs tree built + below the same base path; and it would leave behind what abcd's own + workflow does beyond the generic one (the attested `site.tar.gz` attached + to each release, and the preview deploy on push). None of that is built + here, and abcd's own `site.yml` is unchanged. + +Found and captured, not fixed here: the `_headers` file the build copies is +Cloudflare's, so a project site on GitHub Pages is served without those +security headers (iss-2610142036270003). + +Found and fixed here: the provenance gate refused a two-word author beside the +footer's year ("© 2026 Fixture Author") on every site, because a name was +matched only as a whole piece. ## Footprint -- packages: -- tests: +- packages: internal/adapter/hosting, internal/adapter/hosting/cloudflare, internal/adapter/hosting/githubpages, internal/adapter/hosting/githubpages/githubpagestest, internal/core/repolint, internal/core/site, internal/surface/cli +- tests: the project route (settings only and refused composition keys, the about page from its four parts and each left out when absent, no person's words on any page, the binary's static files, the gate refusing a span from `docs/` or the README on the about page and a record page, the route named both ways, the managed line on every project page, the base path, a hostile package manifest reaching no page while the managed line links the origin remote, the repository's name heading and titling a site with no identity block, the standard record layout read with no record-lint config and its issue ledger only when opted in, no contributors page, an empty build refused before it writes); bare `abcd lint` gating a project site on its own route; setup writing exactly the settings and the workflow, refusing a repository with nothing abcd wrote, and noting the release a project workflow needs; a two-word author beside the footer's year; the base path (normalising, every root-absolute URL prefixed, default output unchanged, the gate against the prefixed root, both verbs' flag); the managed-repository line on every page and its origin fallback; the github-pages adapter over its in-process fake (profile, create, route, address, refusals, every forge failure); setup switched on, declined, private, visibility unreadable, switched off with the workflow removed, kept when hand-edited, kept when the removal is declined; the Pages workflow holding no secret and gated on the switch and on a public repository, pinned by a golden file, and its switch step run with bash against a checkout (switched off at the head, missing or malformed there, made private); a live custom domain without --domain, a site served below another path and an existing site named before it is published over; the base-path gate denied a pass on an unparsed page; the cloudflare workflow pinned byte for byte by a golden file; the forge's visibility read and the `gh api` transport ## Steps -_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. <title>`, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ +1. Build and gate a site served below a base path + - packages: internal/core/site, internal/surface/cli + - tests: basepath_test.go, site_basepath_test.go, the unchanged golden files + - landed: ff9eee7b6 +2. Open every page with the managed-repository line + - packages: internal/core/site + - tests: managedline_test.go, the updated golden files + - landed: ffa77caf5 +3. The github-pages hosting adapter and the seam's profile + - packages: internal/adapter/hosting, internal/adapter/hosting/cloudflare, internal/adapter/hosting/githubpages + - tests: githubpages_test.go, cloudflare_test.go + - landed: 52a12eb24 +4. The switch at setup, public repositories only, the provider-aware workflow and switching off + - packages: internal/core/site, internal/surface/cli + - tests: setup_pages_test.go, setup_workflow_golden_test.go, forge_pages_test.go, site_setup_test.go + - landed: 862ace7f8 +5. Single-source project site + - packages: internal/adapter/hosting, internal/adapter/hosting/githubpages, internal/core/repolint, internal/core/site, internal/surface/cli + - tests: project_test.go, setup_pages_test.go, setup_pages_takeover_test.go, the updated github-pages workflow golden, check_test.go, site_basepath_test.go, githubpages_test.go, rule_site_test.go, the unchanged composed golden files + - landed: 54f5060ad + - The step landed in five commits, 8fbb2f3c5, 19a41f006, 916ff24cc, c22868d2f and 54f5060ad, the last named above; the review's fixes followed on the same branch in 6388e1a56, 364470574, 13991c426, dbc8d4384, 7bc9fecff, 063b04710, eafdefac5 and 38d66d63d, and a second round in 3c6ca3d96, 9871ce11e and 3885b24e1. diff --git a/.abcd/development/specs/open/spc-2610142146250176-abcd-s-own-development-record-is-built.md b/.abcd/development/specs/open/spc-2610142146250176-abcd-s-own-development-record-is-built.md new file mode 100644 index 000000000..8eb76108e --- /dev/null +++ b/.abcd/development/specs/open/spc-2610142146250176-abcd-s-own-development-record-is-built.md @@ -0,0 +1,57 @@ +--- +id: spc-2610142146250176 +slug: abcd-s-own-development-record-is-built +intent: itd-2610142145236613 +origin: researcher-authored +production_mode: hand-written +--- +# abcd-s-own-development-record-is-built + +## Summary + +abcd's own record moves onto abcd's own GitHub Pages, built by the same workflow `site setup --provider github-pages` writes for any project (itd-2610091010348892), and from what abcd writes to the repository and nothing else (the product thinker's single-source ruling of 2026-10-15). abcdev.app becomes abcd's documentation site until a community site replaces it. abcdesign.app is the team-brief editing page for any abcd-managed repository (itd-2610091016262335). This intent is planned now and built after itd-2610091010348892 ships; it is blocked by that intent. + +**The three addresses after the move:** +- **abcd's GitHub Pages address.** It carries abcd's about page and record, built like any project's at each release. No hand-written docs and no file written for the website go into it. +- **abcdev.app.** It carries abcd's documentation, rendered in high quality from `docs/`, from MkDocs Material. abcdev.app's landing page and docs, today composed through `.abcd/site.json` from `docs/`, stay as they are. They are the documentation's own front page, not a project site. +- **abcdesign.app.** It is the static brief-session page, published from a repository of its own (spc-2610141709430146). + +**What changes in abcd's repository:** +- **abcdev.app's deploy narrows to the documentation.** The existing deploy (adr-48: on release, from released bytes) keeps building the docs and their landing page. It stops building the record explorer: `/record/`, `/contributors/` and `/references/` leave abcdev.app. + - Each of those addresses forwards with a 301 to the same path on abcd's GitHub Pages site, query kept. The forwarding goes in the docs host's redirect file (`site-src/redirects`), so no rule is kept outside the repository. + - The docs' own 301s for the paths moved under /docs/ stay. + - Links in the README and docs to abcd's record point at the Pages address. +- **abcd's repository switches its record on to the project route.** The switch is set in abcd's settings and the generated workflow is added. Its about page comes from abcd's identity block, the brief, the newest shipped intents' press releases and the changelog, exactly as for any project. + - abcd's current `.abcd/site.json` composition and `site-src/` stay as the documentation site's own sources. + - The project route reads neither, and its gate fails on any text not from a file abcd writes. +- **Security headers on GitHub Pages.** GitHub Pages ignores `_headers` (iss-2610142036270003). Every document the project route renders carries its content policy as a page element. Framing refusal and the no-sniff header cannot be carried, and that is named in the decision record. abcdev.app keeps its response headers on its own host. +- **The preview of `main`.** It stays with the documentation deploy on abcdev.app. The record has no preview, because a repository has one Pages site. The site-render gate still renders and checks every change. + +**The cut-over is ordered, and each outward step is confirmed by the person first:** +1. The first Pages release is published and checked at the Pages address. +2. The forwarding of the record's addresses is switched on, and a sample of old record links is checked. +3. Only then does the documentation deploy stop building the record. + +A failure at any step leaves the record where it was. + +**Records.** A new decision record amends adr-48: abcdev.app's deploy narrows to the documentation, and the record moves to the project route. adr-48's rule that a site deploys on release, from released bytes, stands for both. + +## Footprint + +- packages: internal/core/site, internal/core/site/setupsrc, internal/adapter/hosting/githubpages, site-src, docs/, .github/workflows, .abcd/site.json +- tests: internal/core/site, internal/adapter/hosting/githubpages, internal/surface/cli + +## Steps + +1. The project route carries its content policy in each page and emits no Cloudflare files (resolves iss-2610142036270003) + - packages: internal/core/site, internal/adapter/hosting/githubpages + - tests: internal/core/site, internal/adapter/hosting/githubpages +2. abcd's repository switches its record on to the project route, and its README and docs link the record at the Pages address + - packages: .abcd/, .github/workflows, docs/ + - tests: internal/core/site, internal/surface/cli +3. abcdev.app's deploy narrows to the documentation and forwards the record's addresses, with the decision record amending adr-48 + - packages: internal/core/site, site-src, .github/workflows, docs/ + - tests: internal/core/site +4. The cut-over, each outward act confirmed by the person: the first Pages release, then the forwarding, then dropping the record from abcdev.app's build + - packages: .github/workflows, site-src + - tests: internal/core/site diff --git a/.abcd/relay/github-app.json b/.abcd/relay/github-app.json new file mode 100644 index 000000000..1f4645e3c --- /dev/null +++ b/.abcd/relay/github-app.json @@ -0,0 +1,3 @@ +{ + "client_id": "Iv23licA3NYws2iI9ZmH" +} diff --git a/.abcd/rules.json b/.abcd/rules.json index 91c1567d6..aead35eb6 100644 --- a/.abcd/rules.json +++ b/.abcd/rules.json @@ -89,6 +89,7 @@ "reserve", "rules", "scribe", + "session", "site", "spec", "status", diff --git a/.abcd/work/decisions/dcn-2610170619127193-a-local-brief-session-reads-its-github.md b/.abcd/work/decisions/dcn-2610170619127193-a-local-brief-session-reads-its-github.md new file mode 100644 index 000000000..d8897cf2c --- /dev/null +++ b/.abcd/work/decisions/dcn-2610170619127193-a-local-brief-session-reads-its-github.md @@ -0,0 +1,6 @@ +--- +id: dcn-2610170619127193 +slug: a-local-brief-session-reads-its-github +date: 2026-10-17 +--- +2026-10-17 — A local brief session reads its GitHub App's public client id from `.abcd/relay/github-app.json` (`{"client_id": "…"}`), which repository setup writes beside `relays.json`; the file holds nothing secret, and `abcd session start` refuses to start without a client id rather than offering no sign-in (facilitator, on delegation, on 2026-10-16; adr-2610161400542916, itd-2610091016262335). diff --git a/.abcd/work/issues/open/iss-2610141947553722-abcd-lint-s-privacy-hygiene-rule-reads-a.md b/.abcd/work/issues/open/iss-2610141947553722-abcd-lint-s-privacy-hygiene-rule-reads-a.md new file mode 100644 index 000000000..a1e339459 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610141947553722-abcd-lint-s-privacy-hygiene-rule-reads-a.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2610141947553722" +slug: "abcd-lint-s-privacy-hygiene-rule-reads-a" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "hosted-sites build, bare abcd lint run" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/development/research/notes/2026-10-14-abcdev-app-github-builds-and-cache.md" +remedy: "Treat a dotted number as an address only where its context reads as one (not after 'section', '§' or 'RFC N'), or skip prose files' section citations, with a test on '[redacted-address]'" +refines: [iss-2609290845269642] +--- + +abcd lint's privacy-hygiene rule reads a dotted RFC section number such as [redacted-address] in prose as a non-reserved IPv4 address and fails the file diff --git a/.abcd/work/issues/open/iss-2610142036270003-on-the-github-pages-hosting-provider-the.md b/.abcd/work/issues/open/iss-2610142036270003-on-the-github-pages-hosting-provider-the.md new file mode 100644 index 000000000..b1b7b83b1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610142036270003-on-the-github-pages-hosting-provider-the.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610142036270003" +slug: "on-the-github-pages-hosting-provider-the" +severity: "minor" +category: "security" +source: "impl-review" +found_during: "itd-2610091010348892 build (github-pages provider)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/adapter/hosting/githubpages/githubpages.go" +remedy: "Carry the declared headers a Pages host can honour into the rendered pages (a CSP meta element per page, generated from site-src/headers), or document that Pages serves none of them and drop _headers from a Pages build." +--- + +On the github-pages hosting provider the site loses the security headers abcd's own site serves: the build copies site-src/headers to _headers, which Cloudflare reads and GitHub Pages ignores, so a project site on GitHub Pages is served without the Content-Security-Policy and the other headers that file declares. diff --git a/.abcd/work/issues/open/iss-2610142306552961-abcd-guard-refused-a-test-harness.md b/.abcd/work/issues/open/iss-2610142306552961-abcd-guard-refused-a-test-harness.md new file mode 100644 index 000000000..4a7c3ed13 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610142306552961-abcd-guard-refused-a-test-harness.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610142306552961" +slug: "abcd-guard-refused-a-test-harness" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "brief session page security round, live harness run" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.js" +remedy: "Read a named script for shell hazards only when it is a shell script (by shebang or extension), or parse JavaScript files as non-shell; add a test with the varint line" +--- + +abcd guard refused a test-harness command as killall-by-name because a JavaScript file the command named contains the line 'num += (b & 0x7f) * mult;', a bitwise AND on a varint byte, which the script reader took for a shell kill diff --git a/.abcd/work/issues/open/iss-2610150102542349-the-brief-session-page-cannot-itself.md b/.abcd/work/issues/open/iss-2610150102542349-the-brief-session-page-cannot-itself.md new file mode 100644 index 000000000..050252253 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610150102542349-the-brief-session-page-cannot-itself.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610150102542349" +slug: "the-brief-session-page-cannot-itself" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "hosted-sites final security review (finding D)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.js" +remedy: "Check the client id's app kind when GitHub documents a stable signal (a client-id format or a public lookup by client id); until then the relay trust anchor (relays.json, or the person's confirmation) is the control, as the page's comment says" +--- + +The brief session page cannot itself tell a GitHub App's client id from an OAuth app's, so a relay the repository names or the person confirmed could report app_type github-app while holding an OAuth app; with no scope requested a fresh grant reaches public data only, but a person who earlier granted that OAuth app wider scopes is signed in again silently with them diff --git a/.abcd/work/issues/open/iss-2610150529485165-a-project-s-site-renders-only-what-abcd.md b/.abcd/work/issues/open/iss-2610150529485165-a-project-s-site-renders-only-what-abcd.md new file mode 100644 index 000000000..998ccd95f --- /dev/null +++ b/.abcd/work/issues/open/iss-2610150529485165-a-project-s-site-renders-only-what-abcd.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610150529485165" +slug: "a-project-s-site-renders-only-what-abcd" +severity: "minor" +category: "future-work-seed" +source: "user-observation" +found_during: "product thinker's SSOT ruling on the per-project site, 2026-10-15" +origin: researcher-authored +production_mode: hand-written +found_at: "per-project GitHub Pages site (itd-2610091010348892)" +remedy: "Plan an intent: when the site would render a figure slot with no image, ask the person for one or offer to generate one, by context, and record the choice. Chosen images are stored in abcd's default folder structure (the committed .abcd/ tiers; the intent names the exact folder), never in a folder written for the website, so the site still renders only what abcd writes. Until then the site renders without pictures" +--- + +A project's site renders only what abcd already writes, so it has no pictures of its own: abcd should ask the person for images, or offer to generate some, depending on the context (the about page's hero, a chapter's figure, the project's kind), and commit what is chosen where the site can render it diff --git a/.abcd/work/issues/open/iss-2610150553031954-when-quoting-the-pt-or-tf-always-show.md b/.abcd/work/issues/open/iss-2610150553031954-when-quoting-the-pt-or-tf-always-show.md new file mode 100644 index 000000000..70dd97bbc --- /dev/null +++ b/.abcd/work/issues/open/iss-2610150553031954-when-quoting-the-pt-or-tf-always-show.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2610150553031954" +slug: "when-quoting-the-pt-or-tf-always-show" +severity: "minor" +category: "process" +source: "user-observation" +found_during: "hosted-sites session, product thinker's instruction 2026-10-15" +origin: researcher-authored +production_mode: hand-written +remedy: "Both, as the product thinker chose: teach it now, in the asking rules every session loads, that a product thinker's or technical facilitator's words are shown to them exactly, with where they will go, before any record quotes them; then build a check that refuses a record whose new verbatim quote was not first shown to the person" +--- + +Before any record quotes the product thinker or the technical facilitator, they are shown their own words, and where those words will go, every time. Today a session writes their words into intents, decision records and issues, and they see them only if they ask. diff --git a/.abcd/work/issues/open/iss-2610150558540639-the-person-still-sees-the-question-guard.md b/.abcd/work/issues/open/iss-2610150558540639-the-person-still-sees-the-question-guard.md new file mode 100644 index 000000000..ecc8b3dc0 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610150558540639-the-person-still-sees-the-question-guard.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2610150558540639" +slug: "the-person-still-sees-the-question-guard" +severity: "minor" +category: "process" +source: "user-observation" +found_during: "hosted-sites session, a capture's remedy question, 2026-10-15" +origin: researcher-authored +production_mode: hand-written +remedy: "Draft first, as the product thinker chose: every question to the product thinker or technical facilitator goes through the abcd question-drafter agent before it is asked, so the guard rarely refuses one in front of the person; teach it in the asking rules every session loads" +--- + +The person still sees the question guard's refusals as errors while a session rewrites a question. On 2026-10-15 one question to the product thinker was refused three times before it reached them: the header, three sentences per option, the row limit, a missing Change later line and a named command. Each refusal showed on their screen as a hook error, because the session drafted the question itself instead of through the drafting agent + +The error framing of a guard refusal on the host is its own open finding, iss-2610070637562567; this record is about the session skipping the drafting step that would have avoided the refusals. diff --git a/.abcd/work/issues/open/iss-2610150656007932-the-brief-s-embark-criterion-says-the.md b/.abcd/work/issues/open/iss-2610150656007932-the-brief-s-embark-criterion-says-the.md new file mode 100644 index 000000000..d862503f1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610150656007932-the-brief-s-embark-criterion-says-the.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610150656007932" +slug: "the-brief-s-embark-criterion-says-the" +severity: "minor" +category: "inconsistency" +source: "review-followup" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/development/brief/01-product/01-press-release.md" +remedy: "Amend the criterion to name brief/01-product/01-press-release.md, as the mapping and the code do; the criterion is the product thinker's to change." +--- + +The brief's embark criterion says the amended press release becomes the new repository's .abcd/development/brief/README.md, but the brief-to-lifeboat mapping (internal/core/lifeboat/mapping.go) and this repository keep the press release at .abcd/development/brief/01-product/01-press-release.md, the path the project site reads its brief opening from. The criterion names a file nothing writes. diff --git a/.abcd/work/issues/open/iss-2610150756067603-a-project-site-switched-off-with-no.md b/.abcd/work/issues/open/iss-2610150756067603-a-project-site-switched-off-with-no.md new file mode 100644 index 000000000..e11e8d24d --- /dev/null +++ b/.abcd/work/issues/open/iss-2610150756067603-a-project-site-switched-off-with-no.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610150756067603" +slug: "a-project-site-switched-off-with-no" +severity: "nitpick" +category: "bug" +source: "review-followup" +found_during: "single-source project site verification, 2026-10-15" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/repolint/rule_site.go" +remedy: "Skip the site gates in repolint for a project site switched off, and let lint site --project report a switched-off empty site as a note rather than a refusal" +--- + +A project site switched off with no abcd-written content left is flagged by bare abcd lint as an error and refused by lint site --project, although setup exempts a switched-off site from the empty check; it needs the record emptied after the site was switched on, so it is rare diff --git a/.abcd/work/issues/open/iss-2610151023089957-revisit-whether-a-project-site-should.md b/.abcd/work/issues/open/iss-2610151023089957-revisit-whether-a-project-site-should.md new file mode 100644 index 000000000..16d182a8a --- /dev/null +++ b/.abcd/work/issues/open/iss-2610151023089957-revisit-whether-a-project-site-should.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610151023089957" +slug: "revisit-whether-a-project-site-should" +severity: "minor" +category: "future-work-seed" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/project.go" +remedy: "Revisit with the product thinker. Until then, project sites show no git-history details." +--- + +Revisit whether a project site should show anything from git history, such as who wrote a record or when it changed. For now a project site shows none of it. diff --git a/.abcd/work/issues/open/iss-2610161444081367-a-local-brief-session-s-relay-abcd.md b/.abcd/work/issues/open/iss-2610161444081367-a-local-brief-session-s-relay-abcd.md new file mode 100644 index 000000000..223cf7a47 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610161444081367-a-local-brief-session-s-relay-abcd.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610161444081367" +slug: "a-local-brief-session-s-relay-abcd" +severity: "minor" +category: "observation" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli/session.go" +remedy: "On a machine with Tailscale running, start abcd session start, run tailscale serve --bg <port>, open the printed link from a second tailnet device, and record the Host the relay receives; widen the Hosts list in newSessionStartCommand to exactly that form if it differs, with a test pinning it." +--- + +A local brief session's relay (abcd session start) refuses any request whose Host header is not the computer's tailnet name or its loopback address, with 421. Which Host header Tailscale Serve passes to the loopback backend was not verified on a live tailnet (the build sandbox could not run the tailscale command), so if Serve sends a form the allowlist does not name, every request through Serve is refused and the session cannot be reached. diff --git a/.abcd/work/issues/open/iss-2610161444092883-the-reworked-brief-session-page-was-not.md b/.abcd/work/issues/open/iss-2610161444092883-the-reworked-brief-session-page-was-not.md new file mode 100644 index 000000000..e4b1f7587 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610161444092883-the-reworked-brief-session-page-was-not.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610161444092883" +slug: "the-reworked-brief-session-page-was-not" +severity: "minor" +category: "observation" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.js" +remedy: "With a GitHub App that has its device flow on and is installed on a test repository, run abcd session start on a tailnet machine and a cloud relay with relays.json, and record a live capture of: signing in by code and by redirect, starting a session that opens on the press release, a guest joining from a second device, a suggestion applied, and the pull request sent; fix whatever breaks." +--- + +The reworked brief session page was not proven end to end against real GitHub: no device-flow or web-flow sign-in, hosting, joining, editing or sending back ran in a browser, because the build sandbox reached neither github.com's sign-in endpoints nor a real GitHub App. Headless screenshots of the start screens on loopback and Go tests against a fake GitHub are the only evidence. diff --git a/.abcd/work/issues/open/iss-2610161904304682-on-a-320-px-phone-the-shared-design-s.md b/.abcd/work/issues/open/iss-2610161904304682-on-a-320-px-phone-the-shared-design-s.md new file mode 100644 index 000000000..4c2450457 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610161904304682-on-a-320-px-phone-the-shared-design-s.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610161904304682" +slug: "on-a-320-px-phone-the-shared-design-s" +severity: "minor" +category: "ux" +source: "impl-review" +found_during: "design fix round, renders at 320 px" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.css" +remedy: "Fold the guest toolbar's labels to icons below 360 px, as the member toolbar does, and add the 320 px guest render to the design tests' width set." +--- + +On a 320 px phone, the Shared design's toolbar for a guest overlaps: the Documents and Your suggestions buttons collide. 390 px is fine. diff --git a/.abcd/work/issues/open/iss-2610161904308800-when-the-session-roster-changes-the.md b/.abcd/work/issues/open/iss-2610161904308800-when-the-session-roster-changes-the.md new file mode 100644 index 000000000..e1d7e7aaf --- /dev/null +++ b/.abcd/work/issues/open/iss-2610161904308800-when-the-session-roster-changes-the.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610161904308800" +slug: "when-the-session-roster-changes-the" +severity: "minor" +category: "ux" +source: "impl-review" +found_during: "design fix round" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.js" +remedy: "Update the invite card's parts in place instead of rebuilding them, or restore focus to the same control after the rebuild; test with a roster change while a card button has focus." +--- + +When the session roster changes, the invite card's contents are rebuilt, so a focused button inside the open invite card loses keyboard focus. diff --git a/.abcd/work/issues/open/iss-2610161904318905-in-the-guided-design-on-a-phone-opening.md b/.abcd/work/issues/open/iss-2610161904318905-in-the-guided-design-on-a-phone-opening.md new file mode 100644 index 000000000..957aac71d --- /dev/null +++ b/.abcd/work/issues/open/iss-2610161904318905-in-the-guided-design-on-a-phone-opening.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610161904318905" +slug: "in-the-guided-design-on-a-phone-opening" +severity: "minor" +category: "ux" +source: "impl-review" +found_during: "design fix round" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.js" +remedy: "Move focus into a phone sheet when it opens and return it to the opener on close, sharing the dialogs' focus helper; test it in layout_test.go." +--- + +In the Guided design on a phone, opening a bottom sheet (documents, suggestions, people, invite) does not move keyboard focus into the sheet, unlike the Send, invite and trust dialogs. diff --git a/.abcd/work/issues/open/iss-2610161904322876-the-session-page-hides-the-editor-s.md b/.abcd/work/issues/open/iss-2610161904322876-the-session-page-hides-the-editor-s.md new file mode 100644 index 000000000..e88fed67b --- /dev/null +++ b/.abcd/work/issues/open/iss-2610161904322876-the-session-page-hides-the-editor-s.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610161904322876" +slug: "the-session-page-hides-the-editor-s" +severity: "minor" +category: "ux" +source: "impl-review" +found_during: "design fix round, the Ctrl+{ switch" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.css" +remedy: "Ask the product thinker whether folding is wanted; if so show the fold gutter (fold arrows only, no line numbers), otherwise remove the fold keymap so the docs name no folding at all." +--- + +The session page hides the editor's gutter, so on Windows and Linux, where Ctrl+Shift+[ and ] now switch the page's design, there is no way left to fold a section of the brief. diff --git a/.abcd/work/issues/resolved/iss-2610150706489874-a-project-site-still-shows-authorship.md b/.abcd/work/issues/resolved/iss-2610150706489874-a-project-site-still-shows-authorship.md new file mode 100644 index 000000000..d10cd895b --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2610150706489874-a-project-site-still-shows-authorship.md @@ -0,0 +1,25 @@ +--- +schema_version: 1 +id: "iss-2610150706489874" +slug: "a-project-site-still-shows-authorship" +severity: "minor" +category: "inconsistency" +source: "review-followup" +found_during: "single-source project site fix round, 2026-10-15" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/site/health.go" +remedy: "Follow the product thinker's ruling on the contributors page: if git authorship counts as not written by abcd, leave the same-author panel and the authorship tallies out of project sites too; if it counts as part of the record, restore the contributors page" +resolution: "Project sites no longer show git-history details: no authors, no declared models, no commit counts and no commit dates, and their build no longer reads git history. This follows the product thinker's ruling." +impact: additive +resolved_by: + commit: "924be8072" +--- + +A project site still shows authorship read from git history, which abcd does not write: the health page's same-author panel names git authors, and record.json carries far more than tallies. On a project build its authorship block lists every author by name, people under "humans" and bots under "bots", each with a profile link wherever their commit address is a GitHub noreply one, and lists every model string the commits declare under "by_model". That is all the data the dropped contributors page showed, so dropping the page hid nothing: anyone can still read it from record.json. The contributors page was dropped from project sites for the same reason, pending the product thinker's confirmation + +The same question covers the record's git-derived dates and counts: record.json carries the repository's history (first and last commit dates, commit count) and each record's created, entered and touched dates, all read from git, and the about page orders its newest shipped intents by the date each entered shipped. None of it carries a data-src, so the gate cannot see it. + +## Grounds + +- pursued: the ruling settled the question, so the fix left every git-history detail off project sites and kept abcd's own site unchanged diff --git a/.abcd/work/issues/resolved/iss-2610161332024554-the-brief-session-page-puts-technical.md b/.abcd/work/issues/resolved/iss-2610161332024554-the-brief-session-page-puts-technical.md new file mode 100644 index 000000000..5df3cf528 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2610161332024554-the-brief-session-page-puts-technical.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-2610161332024554" +slug: "the-brief-session-page-puts-technical" +severity: "major" +category: "observation" +source: "user-observation" +found_during: "live test of the hosted-sites draft, 2026-10-16" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/web/session.js" +remedy: "Never ask a person for a relay address: the host's start command prints a ready link that carries it, and the repository's relay list fills it otherwise; make GitHub sign-in the default with the token tucked away as a fallback; open on the brief's first file; rewrite the screen's words for a product thinker." +resolution: "The brief session page no longer asks for a relay address or a token: it finds its relay from the repository's relays.json or the invite link, or is served by the host's own computer, signs in with GitHub (the web flow on a cloud relay, the device flow on a local session), and opens onto the brief's press release in plain words (adr-2610161400542916)." +impact: fix +resolved_by: + intent: "itd-2610091016262335" + spec: "spc-2610141709430146" +--- + +The brief session page puts technical setup in front of a product thinker before they can see the brief: its first screen asks for a relay address (a term the page never explains), then for a GitHub personal access token, and the brief appears only after hosting and picking a file. Tested by the product thinker on the hosted-sites draft, who could not tell what the page was for or what to enter. A person who only wants to work on the brief should arrive with the relay already chosen and sign in with one GitHub button. diff --git a/.abcd/work/issues/resolved/iss-2610161535126075-adr-2610161400542916-still-lists-the.md b/.abcd/work/issues/resolved/iss-2610161535126075-adr-2610161400542916-still-lists-the.md new file mode 100644 index 000000000..720a61a53 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2610161535126075-adr-2610161400542916-still-lists-the.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2610161535126075" +slug: "adr-2610161400542916-still-lists-the" +severity: "minor" +category: "observation" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +remedy: "add an amendment line to adr-2610161400542916's Alternatives Considered naming the 2026-10-16 ruling and pointing at itd-2610091016262335's Decisions" +resolution: "adr-2610161400542916's shared-app alternative now carries an amendment pointing at the product thinker's ruling: each team registers its own sign-in app, and abcd owns no shared one" +impact: internal +--- + +adr-2610161400542916 still lists the shared abcd-owned GitHub App as a product decision put back to the product thinker, though the product thinker ruled on 2026-10-16 that each team registers its own app (itd-2610091016262335 Decisions) diff --git a/.abcd/work/issues/resolved/iss-2610161535233301-abcd-lint-reports-privacy-hygiene-errors.md b/.abcd/work/issues/resolved/iss-2610161535233301-abcd-lint-reports-privacy-hygiene-errors.md new file mode 100644 index 000000000..1002e79e3 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2610161535233301-abcd-lint-reports-privacy-hygiene-errors.md @@ -0,0 +1,17 @@ +--- +schema_version: 1 +id: "iss-2610161535233301" +slug: "abcd-lint-reports-privacy-hygiene-errors" +severity: "minor" +category: "observation" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/session/server_test.go" +remedy: "mark those deliberately illustrative tailnet addresses with abcd-lint:allow, or teach the privacy-hygiene rule that 100.64.0.0/10 test addresses are reserved for Tailscale's tailnet" +resolution: "the session tests use RFC 5737 documentation addresses, and mark with abcd-lint:allow the tailnet addresses whose 100.64.0.0/10 and fd7a:115c:a1e0::/48 shape the tests exercise; abcd lint reports none in them" +impact: internal +--- + +abcd lint reports privacy-hygiene errors for the tailnet test addresses (100.64.0.0/10, shared address space, not a documentation range) in internal/surface/session/server_test.go and internal/surface/cli/session_surface_test.go, as it does in the tailscale adapter's tests diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md index 204380da6..fb4f4bc1c 100644 --- a/ACKNOWLEDGEMENTS.md +++ b/ACKNOWLEDGEMENTS.md @@ -355,6 +355,45 @@ Ideas and methodologies that shaped the design — not code abcd depends on. dashboard's writes pass through: over plain HTTP it judges a request's Origin against its Host, with no token to keep. <https://pkg.go.dev/net/http#CrossOriginProtection> +- **coder/websocket (Coder, ISC, <https://github.com/coder/websocket>)** — + the WebSocket library the brief session's relay speaks through + (`internal/surface/session`, spc-2610141709430146 step 1): one module with + no dependencies of its own. Like golang.org/x/term it is named here rather + than in `go.mod` alone, because the product thinker approved it by name as + the one dependency the relay adds. +- **The rascals demo repository's join pattern** — the precedent for how a + person joins a brief session and how a team runs its relay + (itd-2610091016262335, adr-2610091016270711): a six-character code from a + 31-symbol alphabet with the look-alike characters left out, drawn from a + cryptographic source; wrong codes limited per address, an IPv6 visitor + counted by its /64, and the forwarded address trusted only behind a + declared proxy; and one free Render web service, deployed from a Blueprint + with its secret unsynced, serving every room. `internal/core/session` and + `abcd session blueprint` follow it. +- **Yjs, y-protocols and y-codemirror.next (Kevin Jahns and contributors, + MIT), CodeMirror 6 (Marijn Haverbeke and others, MIT) and + qrcode-generator (Kazuhiko Arase, MIT)** — the brief session page's shared + document, presence, Markdown editor and QR code + (`abcd session page`, spc-2610141709430146 step 3), vendored as one + provenance-pinned bundle the binary embeds (adr-2610141949110653); every + bundled package and its licence is listed in + `internal/surface/session/web/vendor/`. <https://yjs.dev> + <https://codemirror.net> <https://github.com/kazuhikoarase/qrcode-generator> +- **The W3C Web Annotation Data Model's TextQuoteSelector** — the shape of a + guest's suggestion on the session page: the quoted passage with up to 32 + characters of context either side, the fallback a team member's page + locates the passage by when the suggestion's relative positions no longer + resolve. <https://www.w3.org/TR/annotation-model/#text-quote-selector> +- **PKCE (RFC 7636), the IETF's OAuth 2.0 for Browser-Based Applications and + the OAuth 2.0 Security Best Current Practice (RFC 9700)** — the session + page's GitHub sign-in: an S256 challenge and a random state held in session + storage across the redirect, with the code exchanged by the team's relay as + the token-mediating backend, and the redirect bound to the relay's own + registered callback, so the code reaches only a destination GitHub binds + (adr-2610161400542916's amendment). + <https://www.rfc-editor.org/rfc/rfc7636> + <https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/> + <https://www.rfc-editor.org/rfc/rfc9700> - **TruffleHog (Truffle Security)** — the optional deeper secret scanner the `scan.deep` recommendation keys on when the binary is present (`internal/core/ahoy`); integrated as an opt-in engine, never bundled. @@ -453,39 +492,47 @@ CSL-JSON: [`.abcd/development/research/references.csl.json`](.abcd/development/r 19. Michael Nygard. 2011. Documenting architecture decisions. Cognitect blog (15 November 2011). <https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions> -20. Hammond Pearce, Baleegh Ahmad, Benjamin Tan, Brendan Dolan-Gavitt, and +20. Aaron Parecki, Philippe De Ryck, and David Waite. 2026. OAuth 2.0 for + Browser-Based Applications. IETF OAuth Working Group, + draft-ietf-oauth-browser-based-apps-27 (August 2026), since published as + RFC 10017 (BCP 212). Its token-mediating backend pattern (section 6.2) is + the one the brief session relay's sign-in exchange follows: the page holds + the PKCE verifier and receives the token, and the relay adds the client + secret and makes the exchange (spc-2610141709430146). + <https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/27/> +21. Hammond Pearce, Baleegh Ahmad, Benjamin Tan, Brendan Dolan-Gavitt, and Ramesh Karri. 2022. Asleep at the keyboard? Assessing the security of GitHub Copilot's code contributions. In *Proceedings of the 43rd IEEE Symposium on Security and Privacy (S&P 2022)*, 754–768. [arXiv:2108.09293](https://arxiv.org/abs/2108.09293) -21. Neil Perry, Megha Srivastava, Deepak Kumar, and Dan Boneh. 2023. Do users +22. Neil Perry, Megha Srivastava, Deepak Kumar, and Dan Boneh. 2023. Do users write more insecure code with AI assistants? In *Proceedings of the 2023 ACM SIGSAC Conference on Computer and Communications Security (CCS '23)*, 2785–2799. [doi:10.1145/3576915.3623157](https://doi.org/10.1145/3576915.3623157) -22. Ranjan Sapkota, Konstantinos I. Roumeliotis, and Manoj Karkee. 2025. Vibe +23. Ranjan Sapkota, Konstantinos I. Roumeliotis, and Manoj Karkee. 2025. Vibe coding vs. agentic coding: Fundamentals and practical implications of agentic AI. [arXiv:2505.19443](https://arxiv.org/abs/2505.19443) -23. Advait Sarkar, Andrew D. Gordon, Carina Negreanu, Christian Poelitz, Sruti +24. Advait Sarkar, Andrew D. Gordon, Carina Negreanu, Christian Poelitz, Sruti Srinivasa Ragavan, and Ben Zorn. 2022. What is it like to program with artificial intelligence? In *Proceedings of the 33rd Annual Conference of the Psychology of Programming Interest Group (PPIG 2022)*. [arXiv:2208.06213](https://arxiv.org/abs/2208.06213) -24. Christopher Scaffidi, Mary Shaw, and Brad A. Myers. 2005. Estimating the +25. Christopher Scaffidi, Mary Shaw, and Brad A. Myers. 2005. Estimating the numbers of end users and end user programmers. In *Proceedings of the 2005 IEEE Symposium on Visual Languages and Human-Centric Computing (VL/HCC '05)*, 207–214. [doi:10.1109/VLHCC.2005.34](https://doi.org/10.1109/VLHCC.2005.34) -25. Donald A. Schön. 1983. *The Reflective Practitioner: How Professionals +26. Donald A. Schön. 1983. *The Reflective Practitioner: How Professionals Think in Action*. Basic Books, New York. <https://openlibrary.org/works/OL3466056W> -26. Shivani Shukla, Himanshu Joshi, and Romilla Syed. 2025. Security +27. Shivani Shukla, Himanshu Joshi, and Romilla Syed. 2025. Security degradation in iterative AI code generation — a systematic analysis of the paradox. In *Proceedings of the 2025 IEEE International Symposium on Technology and Society (ISTAS 2025)*. [arXiv:2506.11022](https://arxiv.org/abs/2506.11022) -27. U.S. Copyright Office. 2025. *Copyright and Artificial Intelligence, +28. U.S. Copyright Office. 2025. *Copyright and Artificial Intelligence, Part 2: Copyrightability*. <https://www.copyright.gov/ai/> -28. Gerald M. Weinberg. 1971. *The Psychology of Computer Programming*. Van +29. Gerald M. Weinberg. 1971. *The Psychology of Computer Programming*. Van Nostrand Reinhold, New York. <https://openlibrary.org/works/OL1958820W> -29. Songwen Zhao, Danqing Wang, Kexun Zhang, Jiaxuan Luo, Zhuo Li, and Lei Li. +30. Songwen Zhao, Danqing Wang, Kexun Zhang, Jiaxuan Luo, Zhuo Li, and Lei Li. 2025. Is vibe coding safe? Benchmarking vulnerability of agent-generated code in real-world tasks. [arXiv:2512.03262](https://arxiv.org/abs/2512.03262) diff --git a/commands/ahoy.md b/commands/ahoy.md index e6f8fd400..54000b98a 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -636,8 +636,10 @@ adds the drain eligibility record (below), because the record decides what an unattended agent may change in the repository. When the result carries `optional_skipped`, report it and offer the `yes |` form above as the way to apply it, except `drain_rule.offered` and `conventions.retire_offered`, which only a person at a terminal is asked, -and `readme.offered`, which this page relays as the README question pair -below and answers with `--readme-parts`. +`readme.offered`, which this page relays as the README question pair +below and answers with `--readme-parts`, and `sessions.offered`, which it +relays as the live brief session questions below and answers with +`--live-sessions`. **The git identity question is a person's alone.** When the author or committer a commit would carry diverges from the identity pin, or is a machine identity @@ -766,6 +768,34 @@ the `notes` with its line; relay those notes as they stand. A project with no `README.md` gets one holding only the chosen parts. Committing the result is the owner's act. +**The live brief session questions.** Setup asks "Set up live brief +sessions?" (`yes`, `no`, then deciding later) and, on a yes, "Where may +sessions run?" (`local`, `cloud`, `both`, then deciding later). The answer is +stored as `sessions.live` in `.abcd/config.json`: where sessions may run, or +`none` for a no, which is never asked again. Deciding later stores nothing, +so the next setup run asks again, and so do `abcd session start` and +`abcd session blueprint` at a terminal. At a terminal, in a run without +`--yes`, the install asks them itself, after the README pair; off a terminal, +and under `--yes`, they are never asked, and the result lists +`sessions.offered` under `optional_skipped`. + +Through this page, while the question is open, the bare detection +(`ahoy --json`) carries `sessions` and `sessions_tool`, the two questions as +your question tool's input, built from the words the terminal shows. Ask +`sessions_tool[0]` alone, exactly as given: never reworded, never with a +preview, never with an option marked. On a yes, ask `sessions_tool[1]`. Then +pass the answer: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --live-sessions local|cloud|both|none --json +``` + +A no to the first question is `none`. A `Decide later` at either is no +answer: pass no flag. Any other value is refused with exit 2 before anything +is written. Recording the answer sets nothing up: the steps are +`abcd session setup` (see `/abcd:session`), which waits on the person in a +browser, so tell the user to run it in a Terminal of their own. + **The artefact kind.** A repository with no `.abcd/config/artefact.json` carries an `artefact.missing` gap: the launch verbs choose what to preview, check and scaffold by the kind declared there, and refuse to guess it. Once config changes diff --git a/commands/lint.md b/commands/lint.md index 8bdedeea3..ebe4c07fb 100644 --- a/commands/lint.md +++ b/commands/lint.md @@ -153,7 +153,7 @@ currency-clean. "${CLAUDE_PLUGIN_ROOT}/abcd" lint site --out site ``` -reads the built output directory and the repository it came from, and runs seven +reads the built output directory and the repository it came from, and runs eight independent gates. Each reports EVERY failure it finds rather than the first, so one run is one review round. An output directory with no `index.html` is rendered first: a caller who has not built yet is asking the same question as @@ -165,7 +165,15 @@ one who has. It writes nothing except that render, and reaches no network. a file name or an asset name. The `<title>` and `<meta name="description">` carry Identity text with no attribute to name it, and are checked against the Identity block rather than skipped; a page that names itself reads - `<page> · <project>`, and both halves are held up. + `<page> · <project>`, and both halves are held up. With `--project` (a + project site, built by `abcd site build --project`), every `data-src` on + every page, the record's own pages included, must also name a file abcd + writes: anything under `.abcd/` but the local tier, or `CHANGELOG.md`. A + span from `docs/`, `README.md` or any other file a person wrote fails, and + the interface strings are the binary's own. A project site with no title, + because it has neither an identity block abcd wrote nor a GitHub origin to + name it, fails here too. A gate whose `--project` and `.abcd/site.json` + disagree is refused, both ways. - `hero` — the rendered hero's eyebrow, tagline and pitch equal the Identity block, read through the same parser the positioning surfaces use. - `banned-tokens` — the documentation lint's banned tokens, over the text every @@ -190,6 +198,11 @@ one who has. It writes nothing except that render, and reaches no network. needs a browser and runs in CI. - `figure-labels` — every text label in a lifted diagram is a phrase on the page it illustrates, where the manifest asks for it. +- `base-path` — every root-absolute link a page carries sits under the path + the site is served below, given with `--base-path` (`/widgets/` for a site + served at `owner.github.io/widgets/`); the stylesheet the `mobile` gate reads + is resolved against the same path. At the default, `/`, every root-absolute + link is inside the site and the gate has nothing to find. Report the gates that passed, then every finding under the gate that raised it, each with the file and the `data-src` span a reader's next edit goes to. Exit is diff --git a/commands/session.md b/commands/session.md new file mode 100644 index 000000000..101fbcba5 --- /dev/null +++ b/commands/session.md @@ -0,0 +1,358 @@ +--- +name: session +description: "Set up live brief sessions, start one here, or run, deploy or write the team's relay and page: Writes nothing bare; refuses an unknown sub-verb." +argument-hint: "[setup|start|serve|blueprint|page]" +block: people +--- + +# `/abcd:session` + +The relay a team's live brief session goes through. During a call, the host +starts a session on the project's brief, and everyone else joins by the invite +link, its QR code or a six-character code. Members of the project's team sign +in with GitHub and edit; anyone else joins as a guest with a name and suggests +changes. The page never asks anyone for a relay address or a token: it finds +the relay itself, and opens onto the brief's press release. The session runs +on a relay the team runs, never on anything abcd runs. The relay +holds each session's edits in memory only, as opaque bytes, and drops them +when the host closes the session or nobody has been connected for half an +hour. It writes nothing to disk and logs nothing: no token, no secret, no +brief text. + +The page itself is a set of static files `page` writes, for the team to serve +from any static host. The abcdesign.app address goes live when abcd publishes +the page there; until then a team serves the page itself with `page` and admits +its origin with `--origin`. + +There are two ways to run a session, both the team's own: + +- **On the host's own computer**, with `start`, for a project whose brief + should not leave the team's tailnet. The computer runs the relay and serves + the page itself, reached through Tailscale Serve; nothing goes to + abcdesign.app. Never Funnel: someone outside the tailnet joins as a guest + once the host shares the computer with them in Tailscale. +- **On the team's own relay**, deployed once to the team's own Render account + from the files `blueprint` writes and run there by `serve`, with the page on + abcdesign.app. One deploy serves all of the team's sessions. + +## Status + +Bare invocation prints the five sub-verbs and writes nothing. A running relay's +sessions live in that process's memory alone, so there is no state to report. + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" session --help +``` + +## Setup + +Live sessions are set up once per project, at repository setup: +`abcd ahoy install` asks "Set up live brief sessions?" (Yes, No or Decide +later) and, on a yes, "Where may sessions run?" (Local, Cloud or Both), and +records the answer as `sessions.live` in `.abcd/config.json` (see +`/abcd:ahoy`). The steps themselves are `setup`, which runs by hand at any +time, a no included: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" session setup [--where local|cloud|both] [--release <vMAJOR.MINOR.PATCH>] [--relay-origin <https origin>] +``` + +It waits for the person in a browser, for up to an hour, so never run it from +this session: tell the user to run `abcd session setup` in a Terminal of their +own, in the project's checkout. Where sessions may run comes from `--where`, +else from the recorded answer, else from the question, asked at a terminal; +the answer is recorded once the app is. + +1. **The team's own GitHub sign-in app.** Each team registers its own; abcd + owns no shared app. `setup` prints an address on this computer whose page + opens GitHub's new-app page with the app filled in from a manifest: + Contents and Pull requests read and write, Metadata read, no webhook, not + public, and, for the cloud with `--relay-origin` already given, the + relay's own callback (`https://<relay>/auth/callback`). The person + checks the name and clicks Create GitHub App. GitHub sends the browser + back, and `setup` keeps only the app's public client id, in + `.abcd/relay/github-app.json`. The client secret, the webhook secret and + the private key GitHub hands back are never written or shown. An app + already recorded is kept, and nothing is created twice; for it, `setup` + also says to remove the session page's own address, + `https://abcdesign.app/<owner>/<repo>/brief/`, from the app's callback + URLs if it is listed, since an earlier version of setup listed it and the + sign-in no longer returns there. +2. **Installing it** on the repository, on the page `setup` names. +3. **Enable Device Flow.** A manifest cannot tick it. For Local or Both, + `setup` names the app's settings page and says to tick Enable Device Flow + there, since local sessions sign in with it. For Cloud alone it says to + leave it off: anyone holding the app's public client id can start a device + sign-in. +4. **For the cloud,** it writes the relay's blueprint, as `blueprint` does, + with the app's client id in it, and gives Render's Deploy button. Render's + form asks for `ABCD_SESSION_GITHUB_CLIENT_SECRET`, which the person + generates on the app's settings page and pastes straight into that field, + so no secret passes through abcd. Once Render shows the relay's address, + `setup --relay-origin https://<relay>` records it in + `.abcd/relay/relays.json`, where the session page finds it. +5. **The relay's callback.** The cloud's sign-in returns to the relay's own + address followed by `/auth/callback`, never to the session page. With + `--relay-origin`, `setup` names that exact callback URL and says to list + it on the app's settings page, under Identifying and authorizing users, + with wildcard matching off; before the relay is deployed it names its + shape, `https://<your relay>.onrender.com/auth/callback`. GitHub sends a + sign-in only to a callback the app lists, which ties every sign-in to the + team's relay; until it is listed, sign-in on the relay does not work. + +Relay its steps as printed. It asks `api.github.com` whether the owner is an +organisation (whose apps are registered on the organisation's own page) and +converts the app GitHub created; for the cloud it reads the release's +checksums. `--json` returns `repo`, `where`, `recorded`, `client_id`, +`app_created`, `app_settings`, `app_install`, `blueprint`, `relay_origin`, +`callback_url` (with `--relay-origin`) and `steps`. It refuses (exit 2, nothing written) outside a checkout abcd manages +or one whose origin is not on github.com, with nothing saying where sessions +may run off a terminal, on a `--where` other than local, cloud or both, and on +a `--relay-origin` for local sessions alone. A blueprint that cannot be +written once the app is recorded exits 2 with the app and the answer kept. +Creating someone's GitHub App is +an act on their account, so it never runs without the user asking for it. + +## Start + +Starting a session opens a network listener, so do it only when the user asks +to host a live session from this computer. It runs in the foreground until +stopped with Ctrl-C, and every session ends with it, so never start it from +this session: tell the user to run `abcd session start` in a Terminal of their +own, in the project's checkout. It prints: + +1. the one Tailscale step, `tailscale serve --bg <port>`, which shares it on + the user's tailnet at an https address and which Tailscale keeps until it is + turned off; +2. the link to open, `https://<this computer's tailnet name>/<owner>/<repo>/brief/`, + where the host signs in with GitHub and starts the session, and the page + then shows the invite: a code, a QR code and a link to copy. + +Relay those lines as printed. It serves this checkout's origin repository on +github.com only. Team members sign in with GitHub's device flow: the page +shows a short code that the person confirms on github.com, so no secret sits on +the computer. The GitHub App's public client id comes from `--client-id`, +`ABCD_SESSION_GITHUB_CLIENT_ID`, or `.abcd/relay/github-app.json` +(`{"client_id": "…"}`), which repository setup writes; the file must hold no +secret. It asks Tailscale for this computer's tailnet name (read-only; it +changes nothing in Tailscale), and it answers only that name and its own +loopback address, as hosts and as page origins. It listens on +`127.0.0.1:8787`, or `--port`. `--json` prints `url`, `link`, `repo`, +`tailnet`, `serve_step` and `sign_in`. + +While the live brief session question is unanswered, a person at a terminal +is asked it first, as repository setup asks it; an answer is recorded, and a +yes points to `setup`. It refuses (exit 2, nothing started) outside a checkout +whose origin is on github.com, without a client id ("sign-in is not set up for +this project", naming `abcd session setup`), +with a `github-app.json` that holds a secret or no usable client id, when +Tailscale is not installed or not running, and on a port it cannot listen on. + +## Serve + +`serve` runs the relay for the team's cloud deployment, which the blueprint's +Dockerfile starts. Starting it opens a network listener, so do it only when +the user asks. It runs in the foreground until stopped +with Ctrl-C, and every session it holds ends with it, so never start it from +this session: tell the user to run `abcd session serve` in a Terminal of their +own. It listens on this computer alone (`127.0.0.1:8787`) and prints one line +saying where, with the `tailscale serve --bg <port>` command that puts it on +the user's tailnet at an https address; relay that line as printed. A +non-loopback `--listen` is a second, explicit choice, meant for the team's +relay; pass it only when the user names the address. `--behind-proxy` trusts +the proxy's `X-Forwarded-For` entry as each visitor's address, and belongs to +the team's relay behind its host's proxy. On loopback without it, a visitor +is counted by the tailnet address Tailscale Serve forwards, and only when the +connection itself comes from loopback. By default it admits a page from +`https://abcdesign.app` or loopback; `--origin` (repeatable) replaces that +list, so the relay then admits exactly the origins named, and without +`--origin` so does `ABCD_SESSION_ORIGINS`, a comma list of origins, which the +blueprint sets on the team's relay. An entry for a page served below a base +path names it after the origin, as `page --base-path` wrote it +(`https://<host>/<base>/`); the sign-in's callback forwards only to the +page's own base path, the root for an entry that names none. `--json` prints the +start line as an object with `url`, `loopback`, `behind_proxy`, `sign_in`, +`repos`, `owners`, `any_repository` and `hint`. + +It serves sessions only for the repositories it is given: each `--repo +owner/name`, every repository of each `--owner` (both repeatable), and each +entry of `ABCD_SESSION_REPOS`, a comma list of either. Nobody signs in +against, or hosts, any other. Listening beyond this computer it refuses to +start without one. On loopback, with none given, it serves this checkout's +origin repository, or any repository when run outside a GitHub checkout; the +start line says which. + +Team members sign in with the team's own GitHub App, the only kind of +sign-in app the relay takes, and nobody pastes a token: the relay takes only a +GitHub App user access token (`ghu_`), asks GitHub for its login and whether it +may push to the repository, and drops it straight after. The relay reads the +app's public client id from `--client-id` or `ABCD_SESSION_GITHUB_CLIENT_ID`, +and its secret from `ABCD_SESSION_GITHUB_CLIENT_SECRET` by name. With both, +members sign in through GitHub's web flow: GitHub returns the person to the +relay's `/auth/callback`, which the team's app lists as its callback URL, the +relay sends them on to the session page naming itself, and the page posts the +code to that relay's `/auth/token`, which exchanges only a code given for its +own callback; without both, +sign-in is not offered and only guests can join. GitHub's device flow, which +needs no secret, belongs to `abcd session start` alone, whose relay serves its +own session page; `serve` never offers it. `ABCD_SESSION_GITHUB_APP_TYPE`, +which once chose an OAuth app, may be unset or `github-app`; set to anything +else the relay refuses to start. Never ask the user for the secret, and never +print it or pass it on a command line. + +It refuses (exit 2, nothing started) a `--listen` that is not an IP address +(or `localhost`) and a port, an `--origin` that is not an exact +`scheme://host[:port]`, a `--repo` that is not `owner/name`, an `--owner` or +`ABCD_SESSION_REPOS` entry that is neither, an `ABCD_SESSION_ORIGINS` entry +that is not an origin, `ABCD_SESSION_GITHUB_APP_TYPE` set to anything but +`github-app`, a relay beyond loopback with no repository to serve, and an +address it cannot listen on. + +The relay bounds what it holds: 256 connections, 16 from one address (an IPv4 +address or an IPv6 /48), past which a new one is refused with 503; 30 seconds +for a connection to host, join or rejoin; 1,024 queued items and 8 MiB per +connection, past which that connection is dropped; a member's updates to 60 a +second in bursts of 200; an awareness message to 8 KiB, one connection's +awareness to 32 KiB a second (bursts of 64 KiB), and a session's to 128 KiB a +second; a guest's suggestions to 70 KiB each, 100 and 256 KiB per participant +(a rejoin does not reset them) and 1 MiB per session; every control message +and refused frame to 50 a second per connection, closing one refused 1,000 +times; an update or cursor over its rate is dropped and answered once a second +at most; a connection the relay closes ends within 5 seconds and stays counted +until it has, and a log replay is bounded to a minute; sign-in +exchanges and device sign-ins started to 10 per address in ten minutes +together, and 64 device sign-ins waiting at once; a session's life to 12 hours, +after which it ends for everyone with `closed {reason: "expired"}`; and +joining by code pauses for everybody for ten minutes past 600 wrong codes from +everywhere in ten minutes. + +## Blueprint + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" session blueprint --release <vMAJOR.MINOR.PATCH> [--relay-origin <https origin>] +``` + +It writes `render.yaml`, a Render Blueprint declaring one free web service, +at the repository's root, and `.abcd/relay/Dockerfile`, which downloads the +named abcd +release's linux/amd64 binary and checks it against the SHA-256 that release's +`checksums.txt` publishes. It reads that checksum from the release page when it +writes the files (the one network read it makes, named on stderr) and pins it +in the Dockerfile. `--release` defaults to this abcd's own version when that is +a release. `render.yaml` sets `ABCD_SESSION_REPOS` to this checkout's origin +repository on github.com, the one the relay serves, and `ABCD_SESSION_ORIGINS` +to the session page origins the relay admits: `https://abcdesign.app`, or each +https origin `--origin` (repeatable) names, followed by the page's base path +when it is served below one (`https://<host>/<base>/`). With `--relay-origin`, the +deployed relay's https origin once Render has given it one, it also writes +`.abcd/relay/relays.json`, naming that relay as the one the repository +trusts; the session page reads it. It then prints the callback URL the +team's GitHub App must list for the relay's sign-in, the relay's origin +followed by `/auth/callback`, to add with wildcard matching off. Once +repository setup has recorded the +team's GitHub App, `render.yaml` carries its public client id, so Render's +form asks only for the secret. + +The Blueprint sits at the root because Render's Deploy button takes only the +repository's URL (and a branch) and reads the Blueprint at the root; only +Render's New Blueprint form takes another Blueprint Path. A root `render.yaml` +the team wrote is never touched: the Blueprint then goes to +`.abcd/relay/render.yaml`, picked by its Blueprint Path on that form. While the +live brief session question is unanswered, a person at a terminal is asked it +first. A file left exactly as abcd wrote it is +replaced; one that was edited, or that abcd did not write, is never +overwritten, and then nothing is written (exit 2). It refuses outside a +checkout abcd manages, in a checkout whose origin is not on github.com, a +`--relay-origin` or `--origin` that is not `https://host[:port]`, a release +older than the first to include `abcd session` (the relay runs it; this +abcd's own release is always taken), and a release that publishes no checksum +for the linux/amd64 binary. + +Relay what it prints: each file and whether it was written, replaced or left +unchanged, the release and checksum pinned, and the next steps. The user +commits and pushes the files, then deploys with Render's Deploy button +(`https://render.com/deploy?repo=https://github.com/<owner>/<repo>`), or, with +the Blueprint under `.abcd/relay/`, chooses New, then Blueprint, on Render, +picks the repository and sets the Blueprint Path; Render's form asks for +`ABCD_SESSION_GITHUB_CLIENT_SECRET`, and for the client id too where none is +recorded. `--json` returns `release`, `asset`, `sha256`, `checksums_url`, +`repos`, `origins`, `files`, each file with its `path` and `state`, +`blueprint_path`, `deploy_button`, `deploy_url` and, with `--relay-origin`, +`callback_url`. + +## Page + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" session page --out <dir> [--base-path <path>] +``` + +It writes the session page into `<dir>`: `index.html` and an identical +`404.html`, `session.js`, `session.css`, and `vendor/collab.js` with +`vendor/LICENSES.txt`, the vendored editor bundle and its licences. The files +are embedded in the binary and read nothing from the checkout. A static host +whose 404 fallback serves `404.html`, such as GitHub Pages, is how +`/<owner>/<repo>/brief/` reaches the page. The page loads its own files from +the site's root, or from under `--base-path` when the site is served below one +(a project's GitHub Pages address, `owner.github.io/<repo>/`), so it then opens +at `<base-path>/<owner>/<repo>/brief/`; the base path is plain path segments, +and an address without the trailing slash is sent on to it. A relay admits a +page served below a base path through an `--origin` naming it, +`https://<host>/<base>/`. The page connects +only to the relay the person names and to `api.github.com`, and it sends the +person to `github.com` for sign-in and to open the pre-filled issues a session +sends. The directory is the one the user names; never publish or deploy it for +them. (A session started with `start` serves the same files itself.) + +The page never shows or asks for a relay address. A page `start` serves takes +its own origin as its relay. On a static host, it uses the relay the invite +link names, or, with none, the first relay the repository's committed +`.abcd/relay/relays.json` names (`{"relays": ["https://<relay>"]}`), which it +reads from `api.github.com` on the default branch, without a token for a +public repository and with the person's token once there is one. A link can +name any relay, so the page trusts the repository, not the link: a relay the +file does not name gets no sign-in, no token, no join and no connection, and +the page says the link does not lead to this project's own session. Only +GitHub's 404 means there is no list to read (a private repository before +sign-in, or no file): then a relay from the link reaches nothing until the +person answers one plain question naming the repository ("Only continue if +someone on the owner/repo team sent you this link"). The answer is kept for +that browser tab and at most twelve hours. Members of a private repository +then sign in as on a public one: GitHub returns them only to the callback the +team's app lists, and the page presents the code and the token only to the +relay GitHub returned them to, never to another relay a link named. Any other +failure to read the list (a 403, a 429, +a 5xx, no network) refuses the relay with "try again in a moment", and asks +nothing. With no relay at all, the page says live sessions are not set up for +this project yet and to ask the technical facilitator. So the team commits +`relays.json` naming its cloud relay's https origin, the one +`blueprint --relay-origin` writes. + +Sign-in goes through a GitHub App alone: when a user asks how to set it up, it +is a GitHub App with the repository permissions Contents read and write, Pull +requests read and write, and Metadata read, and only those, installed on the +repository; for `start`, its "Enable Device Flow" setting is switched on. Its +sign-in carries no scope and reaches only the repositories it is installed on. +A relay that offers no GitHub App sign-in gets no sign-in button, and the page +says sign-in is not set up yet and to ask the technical facilitator; guests can +still join. There is no token field. The page refuses to run inside another +page's frame: a static host cannot forbid framing, so the page shows only a +link to open it in its own tab. + +It writes those six files and nothing else, and removes nothing. `<dir>` may be +absent, empty, or a directory an earlier run wrote, whose files are replaced. +It refuses (exit 2, nothing written) a missing `--out`, a `--base-path` that is +not plain path segments, a symlink or a file, and a directory holding anything +else or holding the page's names without the `index.html` it wrote. Relay the +files it wrote and the next step it prints. +`--json` returns `out`, `files`, `bytes`, `base_path` and `replaced`. + +**Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install +provisions the binary into the plugin root, so this is the rung that fires for a +plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails +too, you are in a source checkout of this repo, where — and only there — +`go run ./cmd/abcd` works, the published payload carrying no `cmd/`. To put a +binary on `PATH`, run `ahoy install` through whichever rung just resolved: +`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or +`go run ./cmd/abcd ahoy install` in a source checkout. + +**User input:** $ARGUMENTS diff --git a/commands/site.md b/commands/site.md index 79cb6e6ae..b09584410 100644 --- a/commands/site.md +++ b/commands/site.md @@ -25,6 +25,9 @@ emits `{ "manifest": …, "ui_strings": …, "baseline": …, "out_dir": … }`: - `manifest` — whether `.abcd/site.json` is present. Absent means this repo declares no composition, and there is nothing to render. +- `project` — whether `.abcd/site.json` holds a project site's settings + (`"mode": "project"`) rather than a composition: then there are no chapters + and no `ui_strings`, and the site builds with `--project` (below). - `chapters` — how many chapters the manifest composes the landing page from. - `issue_ledger` — whether the working-tier issue ledger is published. It is an explicit per-repo opt-in; the default renders the durable record only. @@ -56,7 +59,8 @@ reads exactly this set — `.abcd/site.json`, `site-src/ui.json`, the root or in `docs/` — and `CITATION.cff` for the footer), `.abcd/site-baseline.json` (the ratchet the health block counts against), and `.claude-plugin/plugin.json` (the forge URL, licence and author the links and -footer use) — and writes the landing page, the record +footer use; without a forge URL there, the origin remote's github.com address) +— and writes the landing page, the record export, the redirect and header maps, the stylesheet, the two scripts, the `install.sh`, and every referenced raster into the output directory. The one write outside it is `site-src/ui.json` itself, and only when the file lacks a @@ -77,6 +81,59 @@ and the baseline is per-repo site configuration on the same opt-in footing as `.abcd/site.json` itself — the record data proper stays record-format, git and `CHANGELOG.md`. +A site served below the root of its host takes `--base-path`: a project's +GitHub Pages address is `owner.github.io/<repo>/`, so its build passes +`--base-path /<repo>/` and every root-absolute link the build writes (the +stylesheet, the scripts, the pictures, the navigation and every record route) +is placed under that path. Give `abcd lint site` the same `--base-path`, so it +resolves what it reads against the same root. The default, `/`, writes exactly +the links a site served at the root of its domain needs. + +`--project` builds a project site instead, from a `.abcd/site.json` that holds +the site's settings only (`"mode": "project"`, the `hosting` block and its +switch; `abcd site setup --provider github-pages` writes it, below). Nothing is +composed and nothing is read from `site-src/`: every page is built from what +abcd already writes to the repository, and the interface words, the +stylesheet, the scripts and the headers policy come from the binary. The about +page at `/` is, in this order and each only where the repository holds it: the +identity block `.abcd/positioning.json` points at (as the heading), the +brief's opening (the first section with text in +`.abcd/development/brief/01-product/01-press-release.md`), the press releases of +the three newest shipped intents whose audit was met (newest by id: a +timestamped id carries when abcd minted it, and an ordinal one such as `itd-7` +sorts before every timestamped one), and the release list in +`CHANGELOG.md`. Anything absent is left out, never filled; with no identity +block, the page is headed and titled by the repository's name on GitHub. With +neither an identity block nor a GitHub origin, the site has no title, and the +gate refuses it until one of the two is recorded. A +repository holding none of the four parts is refused before anything is +written, since the site would be empty. The repository's address and name +come from the origin remote alone: `.claude-plugin/plugin.json` is not read, so +no name, author or licence written there reaches a page. The record pages are +built as they always are, from the stores `.abcd/record-lint.json` names or, +where the repository carries no such file, from abcd's standard layout under +`.abcd/development/` (intents, specs, ADRs and principles), and the issue +ledger under `.abcd/work/issues`, so an intent's typed link to an issue +resolves; an issue is published only where the settings opt in to it, with +`"record": {"issue_ledger": true}` in `.abcd/site.json`. The +references page (which quotes `ACKNOWLEDGEMENTS.md`) and every picture (which +lives under `docs/`) are left out, and so is a hand-written `docs/` tree or +`README.md`: a project's own documentation is read on GitHub. A project site +also shows nothing read from git history, and the build does not read it: no +contributors page, no timeline, none of the health page's authorship checks +(the same-author pairs, the commits declaring nothing or more than one model, +and the share disclosing assistance), and no `authorship`, `history` or +per-record `dates` in `record.json`. A record is dated by the `date` in its +own frontmatter alone, and one without is undated. +A build whose `--project` and settings file disagree is refused, both ways. + +Every page opens with one line saying it is the record of an abcd-managed +repository, linking to that repository. Its words are the `managed_record` +interface string: on a composed site, the one in `site-src/ui.json`, declared +there like every other word the generator adds; on a project site, the one in +the interface words the binary carries, since a project site reads no +interface words from the repository. + Four flags exist so a build can pin what the footer says rather than reading it from the working tree: `--version`, `--commit`, `--date` and `--preview` (stamp the build as unreleased at this commit, for a preview deployment of an untagged @@ -108,8 +165,8 @@ sets up the site of a repository abcd manages, in three stages, and emits `{ "status": …, "files": […], "environments": […], "host": {…}, "remaining": […], "notes": […] }` (with `added_labels` and `labels_file` when a label was added): -- `files` — the repository half, each `written`, `current`, `kept` or - `refused`: `.abcd/site.json` (derived from the identity block and +- `files` — the repository half, each `written`, `current`, `kept`, `removed` + (a switched-off project site's workflow, below) 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 @@ -150,6 +207,82 @@ 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. +### A project's own GitHub Pages + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" site setup --provider github-pages --json +``` + +publishes the project's about page and development record on its own GitHub +Pages at each release, at `https://<owner>.github.io/<repo>/` (or at the root of +a custom domain given with `--domain`). It is the same verb with a second +provider, and these things differ: + +- **Only what abcd writes.** A project site renders nothing written for the + website: its pages are built from the identity block, the brief, the + development record and `CHANGELOG.md` (see `build --project` above), and the + project's own docs are read on GitHub. A repository holding none of the + about page's four parts yet is refused before anything is asked or written, + since the site would be empty. +- **A release of abcd that builds project sites.** The workflow downloads + abcd's latest release and builds with it, so its build fails until abcd + publishes a release whose `site build` takes `--project`. Setup does not ask + the network which release is latest, so it says this in a note on every run + for a project site. +- **The switch.** Before anything is written, setup asks whether to switch the + project site on, naming the address. A yes writes `.abcd/site.json` holding + the project site's settings (`"mode": "project"` and the `hosting` block with + `"publish": true`, and no composition) and adds `.github/workflows/site.yml`, + which builds and gates with `--project`; nothing else in the repository + changes, and nothing is written under `site-src/`. A no, or an unanswered + run, writes nothing and changes nothing remote, and the status is + `declined`. Settings already written with the switch on, but no workflow, + are asked the switch again before the workflow is added. A composition that + already exists with no `hosting` block is not rewritten: setup refuses and + names the block to add, and once it is added the next run still asks the + switch before it adds the workflow (that repository keeps its composed + site). +- **An existing Pages site.** Setup reads the repository's Pages site before it + asks or writes. One the repository already publishes, built by a workflow, is + named by its address and build type in the switch question and in the host + outcome. `--yes` never answers it: a run under `--yes` refuses before + anything is asked or written, naming the site, unless `--replace-existing` is + passed too, which publishes over it without asking. Pass + `--replace-existing` only after a question naming the live site's address + was answered yes. A site published from a branch, or whose build type GitHub + does not report, one already served at a custom domain when `--domain` names + another or none (the refusal says to declare that domain: `--domain` on the + run that writes the `hosting` block, its `domain` field once the block + exists), and one GitHub serves below another path than the workflow builds + for are refused before anything is written. +- **HTTPS on a custom domain.** GitHub serves a new custom domain over http + until it has issued the domain's certificate, which can take up to a day + after the domain resolves. The address is still reported as `https://…`, and + the host outcome's `detail` says HTTPS is not enforced yet. +- **Public repositories only.** Setup reads whether the repository is public + from the forge first, and refuses a private one (or one whose visibility it + cannot read) in one line, before it asks or writes anything. The workflow + checks again at each release, so a repository made private later publishes + nothing. +- **No secret and no credential.** The deploy job runs in the `github-pages` + environment with the run's own short-lived token (`contents: read`, + `pages: write`, `id-token: write`) and nothing else; there is no host + configuration file and no `gh secret set` step. The host stage creates the + Pages site (built by the workflow), sets the custom domain only when one was + given, and reports the address, acting through `gh` as you, the way the forge + stage does. Environments are `site-render` and `github-pages`. +- **The base path.** The workflow builds and gates the site with + `--base-path /<repo>/`, the path GitHub serves it below (the root for a custom + domain or an `<owner>.github.io` repository). +- **Switching it off.** Set `"publish"` to `false` in the `hosting` block of + `.abcd/site.json`. The workflow reads the switch both at the released commit and + at the head of the default branch, so the next release publishes nothing, and + neither does a later push or dispatch that renders an older tag. A later `abcd site setup` touches nothing + remote and offers to remove the workflow it added, `removed` on a yes; a + workflow edited by hand, or whose removal was declined, is `kept`, with the + reason in its `detail`. Pages already published stay up until they + are unpublished in the repository's settings. + Report each stage's outcome, then the remaining steps in order. Never paste a credential into the conversation: the user stores it themselves with `abcd ahoy credential hosting.cloudflare` (see `/abcd:ahoy`), which explains where it can @@ -159,8 +292,8 @@ never its value. ## The gate over what was rendered Whether a build may be published is `/abcd:lint site` — run `abcd lint site ---out site`. It runs the site's gates over the built output directory and names -every finding at its source. +--out site` (with `--project` for a project site). It runs the site's gates +over the built output directory and names every finding at its source. **Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install provisions the binary into the plugin root, so this is the rung that fires for a diff --git a/docs/how-to/README.md b/docs/how-to/README.md index 4a148b67f..cfeed340b 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -6,6 +6,7 @@ goal for a user who already knows the basics (e.g. "install abcd into a repo", `../explanation/`. - [`install.md`](install.md): Install abcd as a plugin, as a command-line binary, or from source. +- [`run-a-brief-session-relay.md`](run-a-brief-session-relay.md): Run the relay a team's live brief session goes through, on your own machine over Tailscale or on the team's own relay on Render. - [`upgrade-to-v0.12.0.md`](upgrade-to-v0.12.0.md): Upgrade to v0.12.0: the loop's renamed stage fields and the removed command spellings, each with its replacement. - [`upgrade-to-v0.13.0.md`](upgrade-to-v0.13.0.md): Upgrade to v0.13.0: rename abcd's folder from `~/.abcd` to `~/.abcd.noindex` in a plain Terminal window, reconnect its worktrees, recover when both folders exist, and refresh each managed project's block. - [`upgrade-to-v0.14.0.md`](upgrade-to-v0.14.0.md): Upgrade to v0.14.0: replace each retired command with its successor, move memory and source commands under library, read the release cut from the launch preview, refresh each managed project's pre-commit hook, and find the agents' commands. diff --git a/docs/how-to/install.md b/docs/how-to/install.md index e136d795b..b1fce8f38 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -741,6 +741,59 @@ in it; one you made is kept, even when empty. The uninstall's text names what it did on its `readme:` line, for example `readme: links removed; licence edited by hand, left`. +## Live brief sessions + +In a live brief session the team edits the project's brief together during a +call, on the host's own computer over Tailscale, on the team's own relay on +Render, or both ([Run a brief session relay](run-a-brief-session-relay.md)). +Setup asks whether to set them up. + +### The questions + +At a terminal, in a run without `--yes`, setup asks "Set up live brief +sessions?": `yes`, `no`, or `later`, the default. `yes` goes on to "Where may +sessions run?": `local`, `cloud`, `both` or `later`. Each question says what +it decides, its current setting and how to change it later. Through the +plugin, your agent harness asks the same two questions with its own question +tool, one at a time. + +The answer is saved as `sessions.live` in `.abcd/config.json`: `local`, +`cloud`, `both`, or `none` for a no. A no is never asked again. `later`, at +either question, saves nothing, so the next setup run asks again, and so do +`abcd session start` and `abcd session blueprint`, run at a terminal. The +answer is kept in `.abcd/config.json`, so the questions are asked only once +abcd's starter files exist. + +Setup only records the answer. The setting up itself is +`abcd session setup`, which you run in a Terminal of your own: it registers +the team's own GitHub sign-in app from a form GitHub opens already filled in, +keeps only the app's public client id, and says what is left to do. It runs by +hand at any time, after a no too. + +### Answering it with `--live-sessions` + +```sh +'<plugin-root>/abcd' ahoy install --live-sessions both +``` + +```sh +'<plugin-root>/abcd' ahoy install --live-sessions none +``` + +The flag takes `local`, `cloud`, `both`, or `none`, which answers no. It +answers the questions in any run, `--yes` included, and is saved as their +answer is. Any other value is refused with exit 2, naming the four, before +anything is written. A value that differs from the saved one is shown as a +change (`sessions.live: not set -> both`). + +### What `--yes` does + +`--yes` never answers the questions. While they are unanswered, a `--yes` run +without `--live-sessions` lists `sessions.offered` under `optional_skipped`, +and its summary says "The live brief session question was not asked" and +names `--live-sessions`. A run whose answers are piped in is not asked either, +and reports it the same way. + ## Build ```bash diff --git a/docs/how-to/run-a-brief-session-relay.md b/docs/how-to/run-a-brief-session-relay.md new file mode 100644 index 000000000..55882c1e4 --- /dev/null +++ b/docs/how-to/run-a-brief-session-relay.md @@ -0,0 +1,391 @@ +# Run a brief session relay + +A team edits its brief together live through a relay the team runs itself. +abcd runs no relay and holds no key: the relay is part of the `abcd` command, +run either on the host's own computer or as the team's own small web service. +Either way it keeps each session's edits in memory only, drops them when the +host closes the session or nobody has been connected for half an hour, writes +nothing to disk and logs nothing. + +The people who join never see any of this. The session page finds the relay +itself, never asks anyone for a server address or a key, and opens straight +onto the brief's press release. + +Pick one route, or set up both: + +- [On your own computer, over Tailscale](#on-your-own-computer-over-tailscale): + for a project whose brief should not leave your tailnet. Your computer runs + the relay and serves the page itself, so nothing goes to abcdesign.app. +- [On the team's own relay, on Render](#on-the-teams-own-relay-on-render): one + deploy serves all of the team's sessions, the page is at abcdesign.app, and + anyone with the invite link can reach it. + +Both routes sign team members in through the team's own +[GitHub App](#set-up-the-teams-github-app). Each team registers its own; abcd +owns no shared app. + +## Set up live sessions once + +Repository setup asks about live sessions: `abcd ahoy install` asks +"Set up live brief sessions?" and, on a yes, "Where may sessions run?" +(Local, Cloud or Both). It records the answer in `.abcd/config.json`. A no is +never asked again; deciding later asks again at the next setup run and when +someone runs `abcd session start`. To answer without being asked, pass +`--live-sessions local`, `cloud`, `both` or `none` to `abcd ahoy install`. + +Then, in the project's checkout, in a Terminal window of its own: + +```bash +abcd session setup +``` + +It works by hand at any time, after a no too; `--where local|cloud|both` +says where sessions may run when nothing is recorded yet. It does the steps +below in order and prints what is left to you: + +1. It prints an address on your computer. Open it: GitHub opens with the + team's sign-in app filled in. Check its name and click + **Create GitHub App**. abcd keeps only the app's public client id, in + `.abcd/relay/github-app.json`; it never keeps or shows the app's secrets. + Creating an app for an organisation takes someone who may manage its apps. +2. Install the app on the repository, on the page it names. +3. For local sessions, tick **Enable Device Flow** on the app's settings page, + which it names; GitHub's pre-filled form cannot tick it. For the cloud + alone, leave it off. +4. For the cloud, commit and push the files it writes, deploy the relay with + Render's **Deploy** button, paste the app's client secret into Render's + form, and record the relay's address, as + [the Render route](#on-the-teams-own-relay-on-render) describes. + +## On your own computer, over Tailscale + +1. In the project's checkout, start the session in a Terminal window of its + own: + + ```bash + abcd session start + ``` + + It listens on this computer alone, at `127.0.0.1:8787` (`--port` names + another), serves sessions for the repository the checkout's `origin` names + on GitHub, and prints two steps. It runs until you press Ctrl-C, and every + session ends then. It needs the GitHub App's client id, which it reads from + `.abcd/relay/github-app.json` (or `--client-id`), and Tailscale running on + this computer, whose name it reads without changing anything. + +2. The first step it prints shares it on your tailnet with Tailscale Serve: + + ```bash + tailscale serve --bg 8787 + ``` + + Run it once; Tailscale keeps it until you turn it off with + `tailscale serve --https=443 off`. Use Serve only, never Funnel: Funnel + would put your computer on the open internet. + +3. The second step is the link to open, `https://<your computer's tailnet name>/<owner>/<repo>/brief/`. + Sign in with GitHub: the page shows a short code, you confirm it on + github.com, and no secret ever sits on your computer. Start the session, + and the page shows a code, a QR code and a link to copy for inviting + people. + +Teammates on your tailnet open the invite link. Someone outside your tailnet, +such as a domain expert or a stakeholder, can join once you share this +computer with them through Tailscale's sharing; they join as a guest. + +The relay answers only your computer's tailnet name and its own loopback +address, and admits only the page it serves itself. + +## On the team's own relay, on Render + +`abcd session setup` does steps 1 and 3 itself, with the team's client id +already in `render.yaml`, and prints the rest. + +1. In the project's checkout, write the blueprint: + + ```bash + abcd session blueprint --release <vMAJOR.MINOR.PATCH> + ``` + + Name the abcd release the relay should run: one that includes the + `abcd session` command, since the relay runs it. Left out, the release is + the abcd you are running; an older release is refused. The command writes + `render.yaml` at the repository's root and `.abcd/relay/Dockerfile`. The + Dockerfile downloads that release's linux/amd64 binary and checks it + against the checksum the release published, which the command reads from + the release page and pins in the file. It never overwrites a file you + edited. + + `render.yaml` sits at the root because Render's Deploy button reads the + Blueprint there and takes no other path. If your repository already has a + `render.yaml` of its own, abcd leaves it alone and writes its Blueprint to + `.abcd/relay/render.yaml` instead. + + `render.yaml` also names, in `ABCD_SESSION_ORIGINS`, the session pages the + relay admits: `https://abcdesign.app`, unless you serve the page yourself, + in which case name your site's https address with + `--origin https://<your site>` (repeatable), followed by the page's base + path if you serve it below one (`--origin https://<your site>/<base path>/`). + Once the team's GitHub App is + recorded in `.abcd/relay/github-app.json`, it carries the app's public + client id too. + + The relay serves sessions only for this repository, which `render.yaml` + names in `ABCD_SESSION_REPOS` from the checkout's `origin`. To serve more, + edit that value: a comma list of `owner/name`, or an owner alone for all of + its repositories. A relay that is not on your own machine refuses to start + without it, so nobody else can hold its sessions with a repository of their + own. + +2. Commit the files and push them. + +3. Deploy with Render's Deploy button, the link the command prints: + `https://render.com/deploy?repo=https://github.com/<owner>/<repo>`. Render + creates one free web service that runs `abcd session serve` behind its own + proxy. For a private repository, install Render's own GitHub App on it + first. With the Blueprint under `.abcd/relay/`, choose **New**, then + **Blueprint** on Render instead, pick the repository, and set the + Blueprint Path to `.abcd/relay/render.yaml`. + +4. Render's form asks for `ABCD_SESSION_GITHUB_CLIENT_SECRET`, and for + `ABCD_SESSION_GITHUB_CLIENT_ID` where none was recorded. On your + [GitHub App](#set-up-the-teams-github-app)'s settings page, under + **Client secrets**, generate a new client secret and paste it straight into + that field: it never passes through abcd. With both, team members sign in + with one click. Without them, nobody can sign in, and only guests can join. + +5. Once Render shows the service's address, name it as the relay the + repository trusts, and commit the file this writes: + + ```bash + abcd session setup --relay-origin https://<your service>.onrender.com + ``` + + (`abcd session blueprint --release <vMAJOR.MINOR.PATCH> --relay-origin …` + does the same.) It adds `.abcd/relay/relays.json`, which the session page + reads from the repository to find the relay that belongs to it. Nobody + types the address anywhere. + +6. The command also prints the relay's sign-in callback, + `https://<your service>.onrender.com/auth/callback`. On your + [GitHub App](#set-up-the-teams-github-app)'s settings page, under + **Identifying and authorizing users**, list it as a **Callback URL**, with + wildcard matching off, and save. GitHub sends a sign-in only to a callback + URL the app lists, so this ties every sign-in to your relay. Until it is + listed, signing in on the relay does not work. + +The blueprint turns automatic deploys off: a session lives in the relay's +memory, so a redeploy ends every open session. Deploy by hand between calls. +A free Render service also sleeps after a quiet spell and wakes on the next +visit, and a relay that slept holds no session. +To move to a newer abcd release, run `abcd session blueprint` again with that +release; a file you have not edited is replaced. + +## Set up the team's GitHub App + +A team member proves they can change the repository by signing in with GitHub, +through a GitHub App the team registers once. Nobody pastes a token: the relay +takes a GitHub App's sign-in and nothing else. An App's sign-in reaches only +the repositories the App is installed on, so even a relay you did not mean to +trust cannot use it on your other repositories. The relay asks GitHub for the +person's login and whether they may push to the repository, and keeps neither +the sign-in nor anything else once it has the answer. + +`abcd session setup` registers it from a pre-filled form and records its +client id; the steps below are what it fills in, for registering it by hand. + +1. On GitHub, register a new GitHub App under the team's organisation or your + account. Give it exactly the repository permissions the page uses, and no + others: **Contents** read and write, **Pull requests** read and write, and + **Metadata** read. It needs no webhook. Install it on the repositories your + sessions are for, and only those. +2. For sessions on the team's relay: set its callback URL to the relay's own + address followed by `/auth/callback`, such as + `https://<your service>.onrender.com/auth/callback`, with wildcard matching + off. GitHub returns the person there after they sign in; the relay sends + them back to the session page, and the page hands GitHub's code to that + relay and no other. The session page's own address is not a callback: + if an earlier setup listed `https://abcdesign.app/<owner>/<repo>/brief/`, + remove it, and `abcd session setup` says so for an app it recorded before. + Generate a client secret and give both to the relay through its + environment: + - `ABCD_SESSION_GITHUB_CLIENT_ID`: the client id, which is public. + - `ABCD_SESSION_GITHUB_CLIENT_SECRET`: the secret. The relay reads it by + this name, adds it only to its own exchange with GitHub, and never logs + it or sends it to a browser. +3. For sessions on your own computer: tick **Enable Device Flow** in the app's + settings, and record its client id in the repository, with no secret: + + ```json + {"client_id": "<the app's client id>"} + ``` + + as `.abcd/relay/github-app.json`. `abcd session start` refuses a file that + holds a `client_secret`. Leave the device flow off if you only use the + team's relay: anyone who knows the public client id can start a device + sign-in and ask someone to confirm its code. Only `abcd session start` + offers the device flow; the team's relay (`abcd session serve`) signs in + through the web flow alone, and without the secret it offers no sign-in. + +An OAuth app is not taken: its sign-in asks for access to every repository +the person can reach. A relay set up earlier with +`ABCD_SESSION_GITHUB_APP_TYPE=oauth-app` refuses to start; register a GitHub +App as above, give the relay its client id and secret, and remove the +variable. Where sign-in is not set up, the page says so and to ask the +technical facilitator; guests can still join. + +## Name your relay in the repository + +A link to the session page can name any relay, so the page on abcdesign.app +does not take the link's word for it. Before any sign-in or guest's name goes +to a relay, the page reads `.abcd/relay/relays.json` from the repository's +default branch: + +```json +{"relays": ["https://<your service>.onrender.com"]} +``` + +Commit this file naming the https address of your team's relay: the one +`abcd session blueprint --relay-origin` writes. Each entry is an origin alone, +with no path. A page your own computer serves with `abcd session start` needs +no entry: it is its own relay. + +- For a public repository the page reads the file without signing in, and + uses the first relay it names. An invite link naming a relay the file does + not name gets no sign-in, no guest's name and no connection: the page says + the link does not lead to this project's own session. +- For a private repository, or one without the file, GitHub answers that + there is no file to read, and the page cannot check the link's relay. It + names the project and asks one plain question before a guest joins: "Only + continue if someone on the project's team sent you this link." The answer + holds for that browser tab, for at most twelve hours. Team members then + sign in with GitHub as they would on a public repository: GitHub returns + them only to the callback your app lists, and the page hands the sign-in to + that relay alone, whatever relay the link named. +- With no relay in the link and none in the file, the page says that live + sessions are not set up for this project yet, and to ask the technical + facilitator. +- If GitHub does not answer at all (it refuses, limits the page's requests, or + fails), the page cannot check the relay, and refuses it without asking: + "try again in a moment". + +Keeping the relay and its secret safe is the team's responsibility. + +## Serve the session page + +The page everyone opens is a few plain files that abcd writes for you to put +on a static host of your choice. + +1. Write them into an empty directory: + + ```bash + abcd session page --out site + ``` + + The command writes `index.html`, an identical `404.html`, the page's script + and stylesheet, and the editor bundle under `vendor/`. It refuses a + directory that holds anything else. + + If the site is served below a path rather than at the root of its domain, + such as a project's GitHub Pages address `owner.github.io/<repo>/`, name + that path with `--base-path <repo>`, and the page opens below it. + +2. Publish that directory on any static host, such as GitHub Pages. The host + must answer an unknown path with `404.html`: that is how + `/<owner>/<repo>/brief/` reaches the page. + +3. Open `https://<your site>/<owner>/<repo>/brief/`. The page finds the relay + in the repository's `relays.json`. If you set up a sign-in app, its + callback URL is still the relay's own `/auth/callback`, not your site. + +4. Start the relay with `--origin https://<your site>`, or, on the team's + relay, write the blueprint with `--origin https://<your site>`, which sets + `ABCD_SESSION_ORIGINS` in `render.yaml`. By default a relay admits a page + from `https://abcdesign.app` or from this computer; the origins you name + replace that list, so the relay then admits your page alone. If you + served the page below a base path, name it after your site's address, + exactly as you gave it to `--base-path`: + `--origin https://owner.github.io/<repo>/`. The relay's sign-in sends the + person back only to the session page under that path, so other pages on + the same address never receive it. + +The page will not run inside another site's frame. A static host cannot tell +browsers to refuse framing, so the page refuses it itself and shows only a +link that opens it in its own tab. + +### The page's two designs + +The session page comes in two designs, so your team can try both: + +- **Guided**, the default: large cards with numbered areas, and a "What + happens next" note. Before the session it shows on each step the page can + go on from, and when live sessions are not set up yet; while the page is + still checking the project, or cannot go on, it says why instead. In the + session it shows for guests only. +- **Shared**: a team bar along the top showing who is here, with the brief + beneath it; the documents and the suggestions sit in panels at either side, + and inviting people and sending to GitHub are icon buttons in the bar. + +Press Ctrl+Shift+[ or Ctrl+Shift+] (Ctrl+{ or Ctrl+}) to switch between +them; it is Ctrl on every computer, a Mac included, and plain Ctrl+[ and +Ctrl+] still indent and outdent in the brief. On Windows and Linux the +editor would fold a section on those two keys instead; the page takes them +for the switch, and as it shows no fold arrows, a section cannot be folded +there (a Mac folds with Cmd+Option+[ and Cmd+Option+], which still work). +The page remembers each person's choice in their own browser. + +In either design, Alt+Shift+D shows or hides the documents and Alt+Shift+S +the panel on the right, and Escape closes whatever is open on top; in the +brief itself, Escape is the editor's. Each panel stays as you left it when +you change design. A phone keeps its own panel state, apart from a wider +window's: there the panels open as sheets from the bottom of the screen. So +does the right panel at the narrower window widths where it slides over the +page, so closing it there leaves it open on a wide window. + +## Who can join + +- Anyone the host gives the invite link or the join code to joins as a guest, + with just a name. A guest sees the brief and everyone's changes, and + suggests changes for a team member to apply or decline. +- A participant who signs in, and whom GitHub reports as able to push to the + repository, joins as a team member and edits directly. + +Someone whose connection drops, or who reloads the page, comes back as the +same participant without the code, even after the host has chosen a new one: +the relay gives each participant's page a rejoin token, which stops working +when the session ends. + +Twenty wrong join codes or rejoin tokens from one address within ten minutes +stop that address from joining until the ten minutes pass. An IPv6 address +counts by its /48, the block a site is usually given. On your own machine, +behind Tailscale Serve, the address is each visitor's own tailnet address, so +one teammate's mistyped codes never lock out the rest. Past six hundred wrong +codes from everywhere together within ten minutes, joining by code pauses for +everybody for ten minutes; someone coming back with their rejoin token, or a +signed-in team member, still gets in. + +A session lasts at most twelve hours from when the host opened it. Then it +ends for everyone, as if the host had ended it, and the host can open a new +one. A rejoin token brings back the role its participant had, so this is also +the longest a team member whose access was removed on GitHub keeps editing a +session they were already in. + +The relay also bounds how fast anyone can send. One address opens at most three +sessions in ten minutes. On one connection, a team member's edits are held to +sixty a second, cursor and selection updates to twenty a second and a guest's +suggestions to one a second, each with a burst allowed. A message over its +limit is dropped with a `rate-limited` error, and the connection stays open. +A cursor update is at most 8 KiB. A guest's suggestion is at most 70 KiB, and +one connection makes at most a hundred, a session holding at most 1 MiB of +them. A connection that keeps sending messages the relay refuses is cut off +after a thousand. One address may sign in through the relay at most ten times +in ten minutes, and the relay holds at most 64 sign-ins waiting for a code to +be confirmed on GitHub, four of them from one address. A sign-in the person +stops on the page, or leaves the page during, is ended at once and frees its +place; past the four, the page says too many sign-ins are in progress and to +wait a few minutes. + +It bounds connections too: 256 in all and 16 from one address. Past either, a +new connection is turned away, and a connection that has not hosted or joined +a session within thirty seconds is closed. A connection whose page stops +reading is dropped once 8 MiB is waiting for it. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7431f7e68..6b76e11ec 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -124,6 +124,7 @@ Apply the install gaps the detection finds: Writes .abcd/, the name-guard hooks --dev track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary --docs-target string whether AGENTS.md carries the managed block, which names abcd: agents_md | skip (default skip); claude_md and both are refused, with the one setting to change --install-tool strings answer yes to installing this missing tool (repeatable): the answer a host's question tool relays; without it a tool is installed only on an answer typed at a terminal, never on the approve-everything flag, a piped answer or CI + --live-sessions string answer the live brief session question: where sessions may run, local, cloud or both, or none, which answers no and is not asked again; stored in .abcd/config.json as sessions.live and honoured in any run, the approve-everything flag included; abcd session setup then does the setting up --only-waiting ask only the setup that waits after an abcd update: the questions this repository never answered and the settings whose default changed, never one answered or deferred with decide later; every kind of change is approved up front, as the yes to the offer abcd update news makes approves them; with the approve-everything flag no question is answered, so each still waits --oracle-backend string oracle backend: host-delegated | native | cli | api | mcp --outbound opt this repo into both outbound checks, which refuse a live agent-session URL or a tool attribution footer: the committed commit-msg hook over every commit message, and the shell guard's check of the title, body and body file a posting gh pr or gh issue command hands the forge; it also sets this clone's git config --local abcd.binary to the abcd copy whose build provenance it verified and, where no core.hooksPath is set and no commit-msg hook is there, writes abcd's copy of that hook into the clone's git hooks directory, and says so when it cannot do either; the choice is recorded in .abcd/config.json as outbound.hook and outbound.forge, so a later install without the flag keeps both @@ -3920,7 +3921,9 @@ Gate the built website, rendering it first when absent: Writes only inside the o **Flags:** ``` - --out string built output directory to check (rendered first if absent) (default "site") + --base-path string path the site is served below on its host, such as /widgets/ for a project's GitHub Pages address; every root-absolute link is placed under it (default "/") + --out string built output directory to check (rendered first if absent) (default "site") + --project gate a project site, every page built from what abcd writes to the repository and the interface words, styles and scripts taken from the binary; .abcd/site.json then holds the site's settings only ``` ### `abcd memory` @@ -4547,6 +4550,238 @@ beside the run, write-once; an ingest that lands no record leaves the run open. abcd scribe ingest --scribe-json scribe.json --dispositions dispositions.md ``` +### `abcd session` + +Set up live brief sessions, start one here, or run, deploy or write the team's relay and page: Writes nothing bare; refuses an unknown sub-verb. + +**Usage:** `abcd session` + +Set up live brief sessions for this project, start one on this computer, run +the relay a team's cloud sessions go through, write the blueprint that deploys +that relay to the team's own Render account, or write the session's static page +for the team to serve. +The relay holds each session's edits in memory only, as opaque bytes, and drops +them when the host closes the session or nobody has been connected for half an +hour; it writes nothing to disk and logs nothing. abcd runs no relay of its own: +the team runs it, on the host's computer reached over Tailscale Serve +(`abcd session start`), or on the team's own relay (`abcd session serve`). + +Bare `abcd session` prints this help and writes nothing. + +#### `abcd session blueprint` + +Write the team relay's Render blueprint and Dockerfile, pinned to a release: Writes render.yaml and .abcd/relay/; refuses an edited file or no GitHub origin. + +**Usage:** `abcd session blueprint [flags]` + +Write the team's own relay deployment into this checkout: render.yaml, a Render +Blueprint declaring one free web service, at the repository's root, where +Render's Deploy button reads it, and .abcd/relay/Dockerfile, +which downloads the released abcd binary for linux/amd64 and checks it against +the SHA-256 that release's checksums.txt publishes. The checksum is read from the +release page when the files are written (the one network read this verb makes, +named on stderr) and pinned in the Dockerfile. The relay serves sessions for this +checkout's origin repository on github.com, named in render.yaml as +ABCD_SESSION_REPOS, a plain value; it refuses a checkout without one. + +render.yaml also names, in ABCD_SESSION_ORIGINS, the page origins the relay +admits: each --origin, an https origin followed by the page's base path when it +is served below one, or https://abcdesign.app by default, and, +once repository setup has recorded the team's GitHub App in +.abcd/relay/github-app.json, its public client id, so Render's form asks +only for the secret. A root render.yaml the team wrote is never touched: the +Blueprint then goes to .abcd/relay/render.yaml, picked by its Blueprint Path on +Render's New Blueprint form. + +While the live brief session question is unanswered, a person at a terminal is +asked it first, as repository setup asks it. + +--relay-origin names the deployed relay's https origin, once Render has given it +one; the verb then also writes .abcd/relay/relays.json, naming it as the relay +this repository trusts, which the session page reads from the repository, and +prints the callback URL the team's GitHub App must list for the relay's sign-in: +the relay's origin followed by /auth/callback. + +--release names the release to pin; it defaults to this abcd's own version when +that is a release. A file left exactly as abcd wrote it is replaced; one that was +edited, or that abcd did not write, is never overwritten, and nothing is written. + +Exit 2 on a refusal, with nothing written. + +**Flags:** + +``` + --origin stringArray a session page origin the relay admits, as https://host[:port], followed by the page's base path when it is served below one, as https://host/base/ (repeatable; default https://abcdesign.app) + --relay-origin string the deployed relay's https origin, as https://host[:port]; given, .abcd/relay/relays.json names it as the relay this repository trusts + --release string the abcd release tag to pin, as vMAJOR.MINOR.PATCH (default: this abcd's own version, when it is a release) +``` + +#### `abcd session page` + +Write the brief session's static page and its editor bundle into a directory: Writes only the page's six files; refuses a directory holding anything else. + +**Usage:** `abcd session page [flags]` + +Write the brief session's page into --out: index.html and an identical 404.html, +session.js, session.css, and the vendored editor bundle with its licences under +vendor/. The files are plain and static, embedded in this binary; serve them +from any static host, such as GitHub Pages, whose 404.html fallback is how +/<owner>/<repo>/brief/ reaches the page. The page connects only to the relay the +person names and to api.github.com with their own token, and it sends the +person to github.com for sign-in and to open the pre-filled issues a session +sends. It reads nothing from this checkout. + +--base-path names the path the site is served under, such as a project's +GitHub Pages address owner.github.io/<repo>/: the page then loads its files from +under it and opens at <base-path>/<owner>/<repo>/brief/. It is plain path +segments, and the root by default. + +It writes those six files and nothing else, and removes nothing. --out may be +absent, empty, or a directory an earlier run wrote, whose files are replaced; a +directory holding anything else, a symlink or a file is refused. + +Exit 2 on a refusal, with nothing written. + +**Flags:** + +``` + --base-path string the path the site is served under, as plain path segments (default "/") + --out string the directory to write the page into: absent, empty, or one an earlier run wrote +``` + +#### `abcd session serve` + +Run the brief session relay in the foreground, on loopback by default: Writes nothing; refuses a bad --listen, or no repository to serve beyond loopback. + +**Usage:** `abcd session serve [flags]` + +Run the brief session relay in the foreground until interrupted. It listens on +this computer alone (127.0.0.1:8787) unless --listen names another address, +which is the second, explicit choice: the team's own relay deployment listens on +0.0.0.0 behind its host's proxy, with --behind-proxy. On a person's own machine, +teammates reach it through Tailscale Serve, on the tailnet only; the start line +prints the command. Never Funnel: a guest outside the tailnet joins through the +team's relay instead. + +It serves sessions only for the repositories it is given: each --repo owner/name, +every repository of each --owner, and each entry of ABCD_SESSION_REPOS, a +comma list of either. Nobody signs in against, or hosts, any other. Listening +beyond this computer it refuses to start without one; on loopback it serves this +checkout's origin repository, or any repository outside a GitHub checkout. + +It serves the session WebSocket at /session, the sign-in's callback at +/auth/callback (the callback URL the team's GitHub App lists) and its exchange +at /auth/token, the GitHub App's public client id at /config and a health check +at /healthz. It admits a browser only from https://abcdesign.app or a loopback +page, or, when --origin is given, from exactly the origins it names, and otherwise, when +ABCD_SESSION_ORIGINS is set, from exactly the origins that comma list names. +An entry for a page served below a base path names it, as abcd session page +--base-path wrote it (https://host/base/); the callback forwards a sign-in only +to the page's own base path, the root for an entry that names none. +Sign-in goes through the team's GitHub App only, and nobody pastes a token: the +relay takes only a GitHub App user access token. Its client id is read from +--client-id or ABCD_SESSION_GITHUB_CLIENT_ID, and its secret from +ABCD_SESSION_GITHUB_CLIENT_SECRET; with both, members sign in with GitHub's web +flow. Without both, sign-in is not offered and only guests can join: GitHub's +device flow, which needs no secret, belongs to abcd session start alone, whose +relay serves its own session page. +ABCD_SESSION_GITHUB_APP_TYPE set to anything but github-app is refused: the +relay takes no OAuth app. + +Exit 2 on a refusal, with nothing started. + +**Flags:** + +``` + --behind-proxy trust the last X-Forwarded-For entry as the client's address; only for the team relay behind its host's proxy + --client-id string the team's GitHub sign-in app's public client id (default: ABCD_SESSION_GITHUB_CLIENT_ID) + --listen string the address to listen on: an IP address (or localhost) and a port; anything but loopback is listening beyond this computer (default "127.0.0.1:8787") + --origin stringArray a page origin to admit, as scheme://host[:port], followed by the page's base path when it is served below one, as https://host/base/ (repeatable; also ABCD_SESSION_ORIGINS); given, it replaces the default of https://abcdesign.app and loopback pages + --owner stringArray a GitHub user or organisation whose every repository to serve sessions for (repeatable) + --repo stringArray a repository to serve sessions for, as owner/name (repeatable; also ABCD_SESSION_REPOS) +``` + +#### `abcd session setup` + +Set up live brief sessions with the team's own GitHub sign-in app: Writes its client id, the answer and the blueprint; refuses no GitHub origin or no answer. + +**Usage:** `abcd session setup [flags]` + +Set up live brief sessions for this project, the step repository setup asks +about (Set up live brief sessions? Where may sessions run?). It runs by hand at any +time, a no at repository setup included. Where sessions may run comes from +--where, else from .abcd/config.json (sessions.live), else, at a terminal, from +the question; the answer is recorded there. + +It registers the team's own GitHub sign-in app through GitHub's App manifest +flow: it prints an address on this computer whose page opens GitHub with the app +filled in (Contents and Pull requests read and write, Metadata read, no +webhook), and the person clicks Create GitHub App. It keeps only the app's public +client id, in .abcd/relay/github-app.json; the client secret, the webhook +secret and the private key GitHub hands back are never written or shown. An app +already recorded is kept, and setup says to remove the session page's own +address from its callback URLs if it is listed, as an earlier setup had it. + +It then says what is left, with GitHub's own pages: installing the app on the +repository; for local sessions, ticking Enable Device Flow on the app's settings +page, which a manifest cannot do; for the cloud only, leaving it off. For the +cloud it writes the relay's blueprint (as abcd session blueprint does) and gives +Render's Deploy button, whose form asks for the client secret, which the person +generates on the app's settings page and pastes straight in. --relay-origin +records the deployed relay's address in .abcd/relay/relays.json and names the +callback URL the app must list, the relay's origin followed by /auth/callback; +given before the app is created, the manifest sets it. + +It asks api.github.com whether the owner is an organisation, and converts the +app GitHub created; for the cloud it reads the release's checksums. It waits up +to an hour for the app to be created. Where sessions may run is recorded once +the app is. Exit 2 on a refusal before then, with nothing written; a blueprint +that cannot be written afterwards exits 2 with the app and the answer kept. + +**Flags:** + +``` + --relay-origin string the deployed relay's https origin, once Render shows it, as https://host[:port]; recorded in .abcd/relay/relays.json + --release string the abcd release the cloud relay pins, as vMAJOR.MINOR.PATCH (default: this abcd's own version, when it is a release) + --where string where sessions may run: local, cloud or both (default: the answer recorded in .abcd/config.json) +``` + +#### `abcd session start` + +Start a live brief session on this computer, over your tailnet: Writes only an answer it asks; refuses no recorded GitHub App, no Tailscale or no GitHub origin. + +**Usage:** `abcd session start [flags]` + +Start a live brief session on this computer, for a project whose brief should +not leave the team's tailnet. It runs the session relay on loopback and serves the +session page from it, so nothing goes to abcdesign.app, and prints the one +Tailscale step that shares it on your tailnet and the link to open. + +It serves sessions for this checkout's origin repository on github.com. Team +members sign in with GitHub's device flow: the page shows a short code to confirm +on github.com, so no secret sits on this computer. The GitHub App's public client +id comes from --client-id, ABCD_SESSION_GITHUB_CLIENT_ID, or +.abcd/relay/github-app.json, which repository setup writes; without +one it refuses, since nobody could sign in, and a person at a terminal is first +asked the live brief session question while it is unanswered. Anyone else joins +as a guest with a +name, and suggests changes. + +It asks Tailscale for this computer's tailnet name and changes nothing in +Tailscale. It answers only that name and its own loopback address, as hosts and +as page origins. People outside your tailnet join as guests once you share this +computer with them in Tailscale. Never Funnel. + +It runs in the foreground until interrupted; every session ends with it. +Exit 2 on a refusal, with nothing started. + +**Flags:** + +``` + --client-id string the team's GitHub App's public client id (default: ABCD_SESSION_GITHUB_CLIENT_ID, then .abcd/relay/github-app.json) + --port int the port on this computer to listen on (0 lets the system choose) (default 8787) +``` + ### `abcd site` Report what the website declares and what was built: Writes nothing; refuses any argument. @@ -4568,11 +4803,13 @@ Render the website into the output directory: Writes there, plus a missing label **Flags:** ``` - --commit string commit for the footer and the build stamp (default: git HEAD) - --date string date for the build stamp (default: the newest release's date) - --out string directory to render into (default "site") - --preview stamp the build as unreleased at this commit, for a preview deployment of an untagged tree - --version string version for the footer and the build stamp (default: the newest dated CHANGELOG heading) + --base-path string path the site is served below on its host, such as /widgets/ for a project's GitHub Pages address; every root-absolute link is placed under it (default "/") + --commit string commit for the footer and the build stamp (default: git HEAD) + --date string date for the build stamp (default: the newest release's date) + --out string directory to render into (default "site") + --preview stamp the build as unreleased at this commit, for a preview deployment of an untagged tree + --project build a project site, every page built from what abcd writes to the repository and the interface words, styles and scripts taken from the binary; .abcd/site.json then holds the site's settings only + --version string version for the footer and the build stamp (default: the newest dated CHANGELOG heading) ``` #### `abcd site setup` @@ -4584,10 +4821,12 @@ Take the website from this checkout to a live address: Writes its files, and the **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 (never the install of a missing gh); without it an unanswered run declines them + --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) + --provider string hosting provider when the composition names none: cloudflare or github-pages (default cloudflare); github-pages asks whether to switch the project site on + --replace-existing publish over a site the host already serves for this repository without asking; with --yes and no --replace-existing, setup refuses when such a site exists + --yes confirm the forge and host changes without being asked (never the install of a missing gh, nor publishing over a site already live, which takes --replace-existing); without it an unanswered run declines them ``` ### `abcd spec` diff --git a/evals/smoke_session_test.go b/evals/smoke_session_test.go new file mode 100644 index 000000000..966055afd --- /dev/null +++ b/evals/smoke_session_test.go @@ -0,0 +1,64 @@ +//go:build smoke + +package evals + +// The session page smoke (spc-2610141709430146): `abcd session page --out` +// runs for real through the built binary into a scratch directory, and the +// test asserts the six files it reports are the ones on disk, the page loads +// its own script, and a base path moves the page's asset references under it. + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func TestSessionPageWritesItsFiles(t *testing.T) { + want := []string{"404.html", "index.html", "session.css", "session.js", "vendor/LICENSES.txt", "vendor/collab.js"} + for _, c := range []struct { + name, base, script string + }{ + {"at the root", "", `src="/session.js"`}, + {"under a base path", "abcdesign", `src="/abcdesign/session.js"`}, + } { + t.Run(c.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "page") + args := []string{"session", "page", "--out", dir, "--json"} + if c.base != "" { + args = append(args, "--base-path", c.base) + } + out, code := runIn(t, t.TempDir(), nil, args...) + if panicked(out) || code != 0 { + t.Fatalf("`abcd %s` exit=%d\n%s", strings.Join(args, " "), code, out) + } + var res struct { + Files []string `json:"files"` + } + if err := json.Unmarshal([]byte(out[strings.Index(out, "{"):]), &res); err != nil { + t.Fatalf("--json: %v\n%s", err, out) + } + got := append([]string(nil), res.Files...) + sort.Strings(got) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("reported %v, want %v", got, want) + } + for _, f := range want { + info, err := os.Stat(filepath.Join(dir, filepath.FromSlash(f))) + if err != nil || info.Size() == 0 { + t.Fatalf("%s: %v (or empty)", f, err) + } + } + index, err := os.ReadFile(filepath.Join(dir, "index.html")) + if err != nil || !strings.Contains(string(index), c.script) { + t.Fatalf("index.html does not load %s: %v", c.script, err) + } + notFound, err := os.ReadFile(filepath.Join(dir, "404.html")) + if err != nil || string(notFound) != string(index) { + t.Fatal("404.html is not the index") + } + }) + } +} diff --git a/go.mod b/go.mod index 90682b7dc..06a5ac0d1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/intentdriven/abcd go 1.26.9 require ( + github.com/coder/websocket v1.8.15 github.com/minio/selfupdate v0.6.0 github.com/sigstore/sigstore-go v1.3.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 6afcd9c8c..4c7a820e4 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/README.md b/internal/README.md index 9b6e5eecd..0eed1e18f 100644 --- a/internal/README.md +++ b/internal/README.md @@ -93,6 +93,12 @@ plugin surface, and a future MCP server share one engine. over them. History importing either assembler to learn the grammar would pull the assemblers into the transcript store, so the grammar lives here and each of them imports it. +- **`core/session/`** — the brief session relay's engine + (spc-2610141709430146): rooms, join codes, the member and guest roles, each + room's update log held in memory only, guests' suggestions, the Yjs client + ids credited to members, the limits and rates, and `blueprint`, which writes + the team's own relay deployment under `.abcd/relay/`. It never parses the + brief and never writes to disk; `surface/session` carries it over the wire. - **`core/relink/`** — the one link-repoint primitive. A record's folder is its status, so every lifecycle transition is a rename, and a rename strands every relative link that named the file where it was. The verbs that move a record @@ -362,6 +368,18 @@ plugin surface, and a future MCP server share one engine. width and a colour rung, and the sanitising every part passes first. The width comes from `term.Size`; text is measured and wrapped through `textwidth`. +- **`surface/session/`** — the relay's HTTP front door, behind `abcd session + serve`: the WebSocket at `/session` speaking `core/session`'s messages, the + web sign-in's callback at `/auth/callback` and its exchange at + `/auth/token` that adds the team's secret, `/config`, + `/healthz`, and the origin and forwarded-address checks. +- **`surface/session/web/`** — the static session page `abcd session page + --out` writes, embedded in the binary: the HTML, the page script, the + stylesheet and the vendored, provenance-pinned editor bundle. The page joins + a relay, edits the brief live, and sends the session back to GitHub as + pre-filled issues or one pull request with the person's own token. No Node + toolchain enters the repository (adr-47), so its tests are Go tests that pin + the script's structure. - **`surface/mcp/`** *(later)* — an additive front door exposing the same core verbs as `mcp:abcd:*` tools. Added once a surface is worth exposing; no core rework required because the core is transport-agnostic. diff --git a/internal/adapter/hosting/cloudflare/cloudflare.go b/internal/adapter/hosting/cloudflare/cloudflare.go index cbc68e17d..2143fbd63 100644 --- a/internal/adapter/hosting/cloudflare/cloudflare.go +++ b/internal/adapter/hosting/cloudflare/cloudflare.go @@ -1,4 +1,4 @@ -// Package cloudflare is the one hosting provider abcd ships +// Package cloudflare is the hosting provider abcd's own site deploys through // (itd-2609061543533170): an assets-only Cloudflare Worker, the host abcd's own // site is served from. // @@ -95,6 +95,13 @@ func (Adapter) CredentialName() string { return CredentialName } // Secrets are the deploy environment's secret names. func (Adapter) Secrets() []string { return []string{SecretToken, SecretAccount} } +// Profile: the deploy job runs in the `site` environment holding the two +// secrets, reads the repository only, and deploys every release to a Worker +// served at the root of its address. +func (Adapter) Profile(hosting.Site) hosting.Profile { + return hosting.Profile{Environment: "site", Permissions: []string{"contents: read"}, BasePath: "/"} +} + // 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. @@ -401,26 +408,26 @@ func (c *client) Route(ctx context.Context, s hosting.Site) error { // 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) { +func (c *client) Address(ctx context.Context, s hosting.Site) (addr, note string, err error) { if err := checkSite(s); err != nil { - return "", err + return "", "", err } if s.Domain != "" { - return "https://" + s.Domain, nil + return "https://" + s.Domain, "", nil } acct, err := c.accountID(ctx) if err != nil { - return "", err + 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 + 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 "", "", 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 + 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 index b7b9a518a..07cf2ddaa 100644 --- a/internal/adapter/hosting/cloudflare/cloudflare_test.go +++ b/internal/adapter/hosting/cloudflare/cloudflare_test.go @@ -49,7 +49,7 @@ func TestCreateRouteAndReportAgainstTheFake(t *testing.T) { if !st.Exists || !st.Routed { t.Fatalf("after create and route, state is %+v", st) } - addr, err := p.Address(ctx, site) + addr, _, err := p.Address(ctx, site) if err != nil { t.Fatalf("address: %v", err) } @@ -83,7 +83,7 @@ func TestAddressWithoutADomainIsTheWorkersDevName(t *testing.T) { if fake.Writes() != 0 { t.Fatalf("routing a site with no domain wrote: %v", fake.CallLog()) } - addr, err := p.Address(ctx, s) + addr, _, err := p.Address(ctx, s) if err != nil { t.Fatalf("address: %v", err) } @@ -109,7 +109,7 @@ func TestEveryCallFailsLoudly(t *testing.T) { return p.Route(context.Background(), site) }}, {cloudflaretest.GetSubdomain, func(p hosting.Provider) error { - _, err := p.Address(context.Background(), hosting.Site{Name: "example-site"}) + _, _, err := p.Address(context.Background(), hosting.Site{Name: "example-site"}) return err }}, } @@ -268,3 +268,14 @@ func TestRepositoryHalf(t *testing.T) { t.Fatalf("the deploy step runs a shell; it should only call the pinned action:\n%s", step) } } + +// TestTheProfileIsTheSecretHoldingSiteEnvironment: the deploy job runs in the +// `site` environment that holds the two secrets, reads the repository only, +// deploys every release and serves the site at the root of its address. +func TestTheProfileIsTheSecretHoldingSiteEnvironment(t *testing.T) { + p := Adapter{}.Profile(hosting.Site{Name: "example-site"}) + if p.Environment != "site" || len(p.Permissions) != 1 || p.Permissions[0] != "contents: read" || + p.Switched || p.PublicOnly || p.BasePath != "/" { + t.Fatalf("profile = %+v", p) + } +} diff --git a/internal/adapter/hosting/githubpages/githubpages.go b/internal/adapter/hosting/githubpages/githubpages.go new file mode 100644 index 000000000..9b842d50f --- /dev/null +++ b/internal/adapter/hosting/githubpages/githubpages.go @@ -0,0 +1,343 @@ +// Package githubpages is the hosting provider that publishes a project's site +// on its own GitHub Pages (itd-2610091010348892): the project's release +// workflow builds the about page and development record at each release, and +// GitHub hosts them at owner.github.io/<repo>/, or at a custom domain. +// +// The repository half holds no secret. The deploy job runs in the +// `github-pages` environment with the run's own short-lived token and three +// permissions (contents: read, pages: write, id-token: write), and deploys the +// rendered site through actions/upload-pages-artifact and actions/deploy-pages, +// each pinned to a full commit. There is no host configuration file. +// +// The host half speaks the GitHub REST API through the forge, as the person +// who invoked the verb — the way the site setup's forge stage does, so abcd +// holds no GitHub token and resolves no credential: +// +// GET repos/{owner}/{repo} is it public, and has it a Pages site +// GET repos/{owner}/{repo}/pages the site's source, domain and address +// POST repos/{owner}/{repo}/pages create the site, built by a workflow +// PUT repos/{owner}/{repo}/pages route a custom domain to it +// +// These calls are written against GitHub's published REST reference and are +// exercised in this repository only against githubpagestest's in-process fake. +// +// Public repositories only, for now: Pages on a private repository is +// access-controlled only on GitHub's enterprise plan, so a private repository +// is refused rather than published. +package githubpages + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/intentdriven/abcd/internal/adapter/hosting" +) + +// The deploy step's pins: full commits of the two actions' releases, each at +// least two weeks old when it was pinned. +const ( + uploadAction = "actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0" + deployAction = "actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1" +) + +// Environment is the forge environment GitHub Pages deploys through. +const Environment = "github-pages" + +// maxHostMessage bounds how much of a forge error reaches a returned error. +const maxHostMessage = 300 + +// API makes one GitHub REST call through the forge, as the person who invoked +// the verb: method, a repo-relative API path ("repos/acme/widgets/pages") and a +// JSON body or nil. A non-2xx answer is an error naming its HTTP status. +type API func(ctx context.Context, method, path string, body []byte) ([]byte, error) + +// Adapter is the github-pages provider. API is the forge; nil reaches nothing. +type Adapter struct { + API API +} + +var _ hosting.Adapter = Adapter{} + +// Name is the manifest key. +func (Adapter) Name() string { return "github-pages" } + +// CredentialName is empty: the provider acts through the forge as the person. +func (Adapter) CredentialName() string { return "" } + +// Secrets: none. The deploy job uses the run's own token. +func (Adapter) Secrets() []string { return nil } + +// DeployStep hands the unpacked ./site to Pages and deploys it. +func (Adapter) DeployStep() string { + return ` - name: Hand the rendered site to GitHub Pages + uses: ` + uploadAction + ` + with: + path: site + + - name: Deploy to GitHub Pages + id: deployment + uses: ` + deployAction + ` +` +} + +// HostConfig: the provider reads no configuration file. +func (Adapter) HostConfig(hosting.Site) (string, []byte) { return "", nil } + +// Profile: the `github-pages` environment, the run's token with Pages and +// OIDC rights, the switch, public repositories only, a project site built +// from what abcd writes, and the project path GitHub serves the site below. +func (Adapter) Profile(s hosting.Site) hosting.Profile { + return hosting.Profile{ + Environment: Environment, + Permissions: []string{"contents: read", "pages: write", "id-token: write"}, + Switched: true, + PublicOnly: true, + Project: true, + BasePath: basePath(s), + Address: address(s), + } +} + +// address is where GitHub will serve the site: the custom domain, or the +// owner's github.io host below the base path. +func address(s hosting.Site) string { + owner, _, ok := splitRepo(s.Repo) + switch { + case s.Domain != "": + return "https://" + s.Domain + "/" + case !ok: + return "" + } + return "https://" + strings.ToLower(owner) + ".github.io" + basePath(s) +} + +// basePath is where GitHub serves the site: the root of a custom domain, the +// root of an owner's own owner.github.io repository, and /<repo>/ for any other +// repository. +func basePath(s hosting.Site) string { + owner, name, ok := splitRepo(s.Repo) + if !ok || s.Domain != "" || strings.EqualFold(name, owner+".github.io") { + return "/" + } + return "/" + name + "/" +} + +// Connect returns the host half. The provider holds no token, so the one it is +// handed is ignored. +func (a Adapter) Connect(string) hosting.Provider { return &client{api: a.API} } + +var ( + // nameRe is a GitHub owner or repository name, as ahoy's remote parser + // accepts one; "." and ".." are refused separately. + nameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,100}$`) + domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`) + // addrRe is the address Pages reports: http or https, a plain host, a + // plain path. Address accepts http only for the site's own domain. + addrRe = regexp.MustCompile(`^https?://[a-z0-9.-]+(/[A-Za-z0-9_.~-]+)*/?$`) +) + +func splitRepo(repo string) (owner, name string, ok bool) { + owner, name, ok = strings.Cut(repo, "/") + for _, p := range []string{owner, name} { + if !ok || !nameRe.MatchString(p) || p == "." || p == ".." { + return "", "", false + } + } + return owner, name, true +} + +func checkSite(s hosting.Site) error { + if _, _, ok := splitRepo(s.Repo); !ok { + return errors.New("github-pages: the site names no plain owner/name repository on GitHub, so nothing is sent") + } + if s.Domain != "" && !domainRe.MatchString(s.Domain) { + return errors.New("github-pages: the domain is not a plain domain name, so it will not be sent") + } + return nil +} + +type client struct { + api API +} + +// call makes one call; what names it in an error. +func (c *client) call(ctx context.Context, method, what, path string, body any) ([]byte, error) { + if c.api == nil { + return nil, fmt.Errorf("github-pages: %s: the forge is not reachable from here", what) + } + var raw []byte + if body != nil { + var err error + if raw, err = json.Marshal(body); err != nil { + return nil, err + } + } + out, err := c.api(ctx, method, path, raw) + if err != nil { + msg := err.Error() + if len(msg) > maxHostMessage { + msg = msg[:maxHostMessage] + "…" + } + return nil, fmt.Errorf("github-pages: %s: %s", what, msg) + } + return out, nil +} + +type pagesSite struct { + BuildType string `json:"build_type"` + CNAME *string `json:"cname"` + HTMLURL string `json:"html_url"` +} + +func (c *client) pages(ctx context.Context, s hosting.Site) (pagesSite, error) { + out, err := c.call(ctx, "GET", "read the Pages site", "repos/"+s.Repo+"/pages", nil) + if err != nil { + return pagesSite{}, err + } + var p pagesSite + if json.Unmarshal(out, &p) != nil { + return pagesSite{}, errors.New("github-pages: read the Pages site: the response is not a Pages site") + } + return p, nil +} + +// Verify: there is no credential to verify; the forge stage proves the forge. +func (c *client) Verify(context.Context) error { + return errors.New("github-pages: the provider acts through the forge as you and keeps no credential to verify") +} + +// Inspect reads whether the repository is public, whether its Pages site +// exists and is built by a workflow, and whether the domain is routed to it. +// An existing site is named by its address and build type. +func (c *client) Inspect(ctx context.Context, s hosting.Site) (hosting.State, error) { + if err := checkSite(s); err != nil { + return hosting.State{}, err + } + // Setup's own public-only check read this same document through the + // forge; the provider reads it again rather than take it through the + // seam, which carries no forge response (see Setup). + out, err := c.call(ctx, "GET", "read the repository", "repos/"+s.Repo, nil) + if err != nil { + return hosting.State{}, err + } + var repo struct { + Private *bool `json:"private"` + HasPages bool `json:"has_pages"` + } + if json.Unmarshal(out, &repo) != nil || repo.Private == nil { + return hosting.State{}, errors.New("github-pages: read the repository: the response does not say whether it is public") + } + if *repo.Private { + return hosting.State{}, fmt.Errorf("github-pages: %s is private, and the project site is published for public repositories only", s.Repo) + } + st := hosting.State{Routed: s.Domain == ""} + if !repo.HasPages { + return st, nil + } + p, err := c.pages(ctx, s) + if err != nil { + return hosting.State{}, err + } + switch p.BuildType { + case "workflow": + case "legacy": + return hosting.State{}, fmt.Errorf("github-pages: %s already publishes GitHub Pages from a branch; abcd will not move a live site "+ + "off it, so set its Pages source to GitHub Actions in the repository's settings and re-run", s.Repo) + default: + // An empty or unknown build type says nothing about how the live site + // is built, so it is neither called a branch site nor published over. + return hosting.State{}, fmt.Errorf("github-pages: %s does not say how its Pages site is built (no build type abcd knows), so abcd "+ + "will not publish over it; check its Pages source in the repository's settings, set it to GitHub Actions, and re-run", s.Repo) + } + st.Exists = true + addr := p.HTMLURL + if !addrRe.MatchString(addr) { + addr = "an address that is not a plain http or https URL" + } + st.Existing = addr + " (build type " + p.BuildType + ")" + if p.CNAME != nil && *p.CNAME != "" { + cname := *p.CNAME + printable := domainRe.MatchString(cname) + if !printable { + cname = "a domain with an unprintable name" + } + switch { + case s.Domain == "" && printable: + // The workflow would build for /<repo>/ while GitHub serves the + // site at the domain's root, so every asset would 404. + // The domain is declared in one of two places: the flag, on the + // run that writes the hosting block, or the block's own field + // once it exists (setup refuses the flag then). + return hosting.State{}, fmt.Errorf("github-pages: %s already serves its Pages site at %s; abcd will not move a live domain, so declare %s "+ + "as the site's domain: pass --domain %s when setup writes the hosting block, or set the hosting block's domain to %s "+ + "when the composition already has one", s.Repo, cname, cname, cname, cname) + case *p.CNAME != s.Domain: + return hosting.State{}, fmt.Errorf("github-pages: %s already serves its Pages site at %s; abcd will not move a live domain", s.Repo, cname) + } + st.Routed = true + } + return st, nil +} + +// Create creates the Pages site, built by the release workflow. +func (c *client) Create(ctx context.Context, s hosting.Site) error { + if err := checkSite(s); err != nil { + return err + } + _, err := c.call(ctx, "POST", "create the Pages site", "repos/"+s.Repo+"/pages", map[string]string{"build_type": "workflow"}) + return err +} + +// Route sets the custom domain. A site with no domain is a no-op. +func (c *client) Route(ctx context.Context, s hosting.Site) error { + if err := checkSite(s); err != nil { + return err + } + if s.Domain == "" { + return nil + } + _, err := c.call(ctx, "PUT", "route the domain", "repos/"+s.Repo+"/pages", map[string]string{"cname": s.Domain}) + return err +} + +// httpsPending is the note on a custom domain GitHub still serves over http. +const httpsPending = "HTTPS is not enforced yet; GitHub issues the certificate after the domain resolves, which can take up to a day, " + + "and then Enforce HTTPS can be switched on in the repository's Pages settings" + +// Address is the address Pages reports for the site, always as https. +// +// A custom domain is reported as http until GitHub has issued its certificate +// and HTTPS is enforced: GitHub requests the certificate only once the domain's +// DNS resolves to Pages, DNS changes can take up to 24 hours, and "it can take +// up to 24 hours before" Enforce HTTPS is available +// (https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site, +// https://docs.github.com/en/pages/getting-started-with-github-pages/securing-your-github-pages-site-with-https). +// So an http address on the site's own domain is the site's: host and path +// are what identify it, the scheme only says how far along the certificate +// is, and the note says so. A github.io address is served over https from the +// start, so http there, or on any other host, is refused. +func (c *client) Address(ctx context.Context, s hosting.Site) (string, string, error) { + if err := checkSite(s); err != nil { + return "", "", err + } + p, err := c.pages(ctx, s) + if err != nil { + return "", "", err + } + u, err := url.Parse(p.HTMLURL) + if !addrRe.MatchString(p.HTMLURL) || err != nil { + return "", "", errors.New("github-pages: the Pages site reports no plain https address") + } + if u.Scheme == "https" { + return p.HTMLURL, "", nil + } + if s.Domain == "" || u.Host != s.Domain { + return "", "", errors.New("github-pages: the Pages site reports an http address that is not the site's own domain") + } + return "https://" + u.Host + u.Path, httpsPending, nil +} diff --git a/internal/adapter/hosting/githubpages/githubpages_test.go b/internal/adapter/hosting/githubpages/githubpages_test.go new file mode 100644 index 000000000..cab68c6a1 --- /dev/null +++ b/internal/adapter/hosting/githubpages/githubpages_test.go @@ -0,0 +1,271 @@ +package githubpages + +// The github-pages provider over githubpagestest's in-process fake of the +// GitHub REST API. Nothing here reaches a network. + +import ( + "context" + "errors" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/githubpages/githubpagestest" +) + +func connect(f *githubpagestest.Server) hosting.Provider { return Adapter{API: f.API}.Connect("") } + +var widgets = hosting.Site{Name: "widgets", Repo: "acme/widgets"} + +func TestTheRepositoryHalfHoldsNoSecret(t *testing.T) { + a := Adapter{} + if a.Name() != "github-pages" { + t.Fatalf("name = %q", a.Name()) + } + if a.CredentialName() != "" { + t.Fatalf("credential = %q; the provider acts through the forge as the person and resolves none", a.CredentialName()) + } + if len(a.Secrets()) != 0 { + t.Fatalf("secrets = %v, want none", a.Secrets()) + } + if rel, data := a.HostConfig(widgets); rel != "" || data != nil { + t.Fatalf("host config = %q, want none", rel) + } + step := a.DeployStep() + pinRe := regexp.MustCompile(`uses: actions/(upload-pages-artifact|deploy-pages)@[0-9a-f]{40} # v[0-9]+\.[0-9]+\.[0-9]+\n`) + if got := len(pinRe.FindAllString(step, -1)); got != 2 { + t.Fatalf("the deploy step pins %d of the two Pages actions to a full commit:\n%s", got, step) + } + for _, want := range []string{"path: site\n", "id: deployment\n"} { + if !strings.Contains(step, want) { + t.Errorf("the deploy step lacks %q:\n%s", want, step) + } + } + if strings.Contains(step, "secrets.") || strings.Contains(step, "${{") { + t.Fatalf("the deploy step reads a secret or an expression:\n%s", step) + } +} + +func TestTheProfileIsTheRunsOwnTokenOnly(t *testing.T) { + p := Adapter{}.Profile(widgets) + want := hosting.Profile{ + Environment: "github-pages", + Permissions: []string{"contents: read", "pages: write", "id-token: write"}, + Switched: true, PublicOnly: true, Project: true, BasePath: "/widgets/", Address: "https://acme.github.io/widgets/", + } + if !reflect.DeepEqual(p, want) { + t.Fatalf("profile = %+v, want %+v", p, want) + } + for _, c := range []struct { + site hosting.Site + base string + }{ + {hosting.Site{Name: "w", Repo: "acme/widgets", Domain: "docs.example.com"}, "/"}, + {hosting.Site{Name: "w", Repo: "Acme/acme.github.io"}, "/"}, + {hosting.Site{Name: "w", Repo: "acme/acme.github.io.old"}, "/acme.github.io.old/"}, + {hosting.Site{Name: "w"}, "/"}, + } { + if got := (Adapter{}).Profile(c.site).BasePath; got != c.base { + t.Errorf("base path for %+v = %q, want %q", c.site, got, c.base) + } + } +} + +func TestInspectCreateAndAddressAProjectSite(t *testing.T) { + f := githubpagestest.New() + p := connect(f) + st, err := p.Inspect(context.Background(), widgets) + if err != nil || st.Exists || !st.Routed { + t.Fatalf("inspect = %+v, %v; want absent and vacuously routed", st, err) + } + if err := p.Create(context.Background(), widgets); err != nil { + t.Fatal(err) + } + if got := f.Repos["acme/widgets"].Pages; got == nil || got.BuildType != "workflow" { + t.Fatalf("pages = %+v, want a site built by the workflow", got) + } + if st.Existing != "" { + t.Fatalf("inspect named an existing site where there is none: %q", st.Existing) + } + if st, err = p.Inspect(context.Background(), widgets); err != nil || !st.Exists { + t.Fatalf("inspect after create = %+v, %v", st, err) + } + if st.Existing != "https://acme.github.io/widgets/ (build type workflow)" { + t.Fatalf("existing = %q, want the site's address and build type", st.Existing) + } + addr, note, err := p.Address(context.Background(), widgets) + if err != nil || addr != "https://acme.github.io/widgets/" || note != "" { + t.Fatalf("address = %q, %q, %v", addr, note, err) + } + if err := p.Route(context.Background(), widgets); err != nil { + t.Fatal(err) + } + for _, c := range f.CallLog() { + if strings.HasPrefix(c, githubpagestest.UpdatePages) { + t.Fatalf("a site with no domain set one: %v", f.CallLog()) + } + } +} + +func TestRouteSetsTheCustomDomain(t *testing.T) { + f := githubpagestest.New() + f.Repos["acme/widgets"].Pages = &githubpagestest.Pages{BuildType: "workflow"} + s := widgets + s.Domain = "docs.example.com" + p := connect(f) + st, err := p.Inspect(context.Background(), s) + if err != nil || !st.Exists || st.Routed { + t.Fatalf("inspect = %+v, %v; want existing and unrouted", st, err) + } + if err := p.Route(context.Background(), s); err != nil { + t.Fatal(err) + } + if got := f.Repos["acme/widgets"].Pages.CNAME; got != "docs.example.com" { + t.Fatalf("cname = %q", got) + } + if st, _ = p.Inspect(context.Background(), s); !st.Routed { + t.Fatal("the domain does not read as routed after Route") + } + if addr, note, err := p.Address(context.Background(), s); err != nil || addr != "https://docs.example.com/" || note != "" { + t.Fatalf("address = %q, %q, %v", addr, note, err) + } +} + +func TestInspectRefuses(t *testing.T) { + for name, c := range map[string]struct { + repo githubpagestest.Repo + site hosting.Site + want string + }{ + "a private repository": {githubpagestest.Repo{Private: true}, widgets, "public repositories only"}, + "a site built from a branch": {githubpagestest.Repo{Pages: &githubpagestest.Pages{BuildType: "legacy"}}, widgets, + "from a branch"}, + "a domain routed elsewhere": {githubpagestest.Repo{Pages: &githubpagestest.Pages{BuildType: "workflow", CNAME: "other.example.com"}}, + hosting.Site{Name: "widgets", Repo: "acme/widgets", Domain: "docs.example.com"}, "other.example.com"}, + // The workflow would be built for /widgets/ while the site is served at + // the domain's root, so every asset would 404. + // The fix differs by where the domain is declared: a flag on the run + // that writes the hosting block, the block's own field once it exists. + "a live domain with none asked for": {githubpagestest.Repo{Pages: &githubpagestest.Pages{BuildType: "workflow", CNAME: "docs.acme.com"}}, + widgets, "already serves its Pages site at docs.acme.com; abcd will not move a live domain, so declare docs.acme.com as the site's domain: " + + "pass --domain docs.acme.com when setup writes the hosting block, or set the hosting block's domain to docs.acme.com when the composition already has one"}, + // A site that does not say how it is built is not one abcd knows it + // can publish over, and it is not called a branch site. + "a site with no build type": {githubpagestest.Repo{Pages: &githubpagestest.Pages{}}, widgets, "does not say how its Pages site is built"}, + "a site with an unknown build type": {githubpagestest.Repo{Pages: &githubpagestest.Pages{BuildType: "future"}}, widgets, + "does not say how its Pages site is built"}, + } { + t.Run(name, func(t *testing.T) { + f := githubpagestest.New() + r := c.repo + f.Repos["acme/widgets"] = &r + _, err := connect(f).Inspect(context.Background(), c.site) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("inspect = %v, want a refusal naming %q", err, c.want) + } + }) + } +} + +func TestEveryForgeFailureIsReported(t *testing.T) { + boom := errors.New("exit status 1 (gh: Server Error (HTTP 500))") + for _, route := range []string{githubpagestest.GetRepo, githubpagestest.GetPages, githubpagestest.CreatePages, githubpagestest.UpdatePages} { + t.Run(route, func(t *testing.T) { + f := githubpagestest.New() + f.Repos["acme/widgets"].Pages = &githubpagestest.Pages{BuildType: "workflow"} + if route == githubpagestest.CreatePages { + f.Repos["acme/widgets"].Pages = nil + } + f.Fail[route] = boom + s := widgets + s.Domain = "docs.example.com" + p := connect(f) + var err error + switch route { + case githubpagestest.GetRepo, githubpagestest.GetPages: + _, err = p.Inspect(context.Background(), s) + case githubpagestest.CreatePages: + err = p.Create(context.Background(), s) + case githubpagestest.UpdatePages: + err = p.Route(context.Background(), s) + } + if err == nil || !strings.Contains(err.Error(), "HTTP 500") || !strings.HasPrefix(err.Error(), "github-pages: ") { + t.Fatalf("%s failure = %v", route, err) + } + }) + } +} + +func TestAnUnsafeSiteIsRefusedBeforeACall(t *testing.T) { + for _, s := range []hosting.Site{ + {Name: "w"}, + {Name: "w", Repo: "acme"}, + {Name: "w", Repo: "acme/widgets/extra"}, + {Name: "w", Repo: "acme/../widgets"}, + {Name: "w", Repo: "acme/wid gets"}, + {Name: "w", Repo: "acme/widgets", Domain: "https://x.example.com"}, + } { + f := githubpagestest.New() + p := connect(f) + if _, err := p.Inspect(context.Background(), s); err == nil { + t.Errorf("inspect accepted %+v", s) + } + if err := p.Create(context.Background(), s); err == nil { + t.Errorf("create accepted %+v", s) + } + if n := len(f.CallLog()); n != 0 { + t.Errorf("%+v reached the forge %d times", s, n) + } + } +} + +func TestAnAddressThatIsNotAPlainHTTPSURLIsRefused(t *testing.T) { + p := Adapter{API: func(context.Context, string, string, []byte) ([]byte, error) { + return []byte(`{"build_type":"workflow","html_url":"javascript:alert(1)"}`), nil + }}.Connect("") + if addr, _, err := p.Address(context.Background(), widgets); err == nil { + t.Fatalf("address = %q, want a refusal", addr) + } +} + +// GitHub reports a custom domain's address as http until it has issued the +// domain's certificate and HTTPS is enforced, which can take a day after the +// domain resolves. That address is still the site's: it is reported as https, +// with a note saying HTTPS is not there yet. An http address is accepted only +// for the site's own domain, and a github.io address only over https. +func TestAnHTTPAddressForTheSitesOwnDomainIsReportedAsHTTPS(t *testing.T) { + docs := hosting.Site{Name: "widgets", Repo: "acme/widgets", Domain: "docs.example.com"} + f := githubpagestest.New() + f.Repos["acme/widgets"].Pages = &githubpagestest.Pages{BuildType: "workflow", CNAME: "docs.example.com", HTMLURL: "http://docs.example.com/"} + addr, note, err := connect(f).Address(context.Background(), docs) + if err != nil || addr != "https://docs.example.com/" { + t.Fatalf("address = %q, %v; want the domain over https", addr, err) + } + if !strings.Contains(note, "HTTPS is not enforced yet") || !strings.Contains(note, "certificate") { + t.Fatalf("note = %q, want one saying HTTPS is not enforced yet", note) + } + for name, c := range map[string]struct { + site hosting.Site + url string + }{ + "http for another host": {docs, "http://elsewhere.example.com/"}, + "http with no domain": {widgets, "http://acme.github.io/widgets/"}, + "a scheme other than http": {docs, "ftp://docs.example.com/"}, + } { + t.Run(name, func(t *testing.T) { + f := githubpagestest.New() + f.Repos["acme/widgets"].Pages = &githubpagestest.Pages{BuildType: "workflow", HTMLURL: c.url} + if addr, _, err := connect(f).Address(context.Background(), c.site); err == nil { + t.Fatalf("address = %q, want a refusal", addr) + } + }) + } +} + +func TestWithoutTheForgeNothingIsCalled(t *testing.T) { + if _, err := (Adapter{}).Connect("").Inspect(context.Background(), widgets); err == nil { + t.Fatal("a provider with no forge inspected the site") + } +} diff --git a/internal/adapter/hosting/githubpages/githubpagestest/fake.go b/internal/adapter/hosting/githubpages/githubpagestest/fake.go new file mode 100644 index 000000000..e773094f7 --- /dev/null +++ b/internal/adapter/hosting/githubpages/githubpagestest/fake.go @@ -0,0 +1,159 @@ +// Package githubpagestest is an in-process fake of the slice of the GitHub +// REST API the github-pages hosting adapter calls, for tests only. The adapter +// speaks through the forge the way the site setup's forge stage does — `gh api` +// as the person who invoked the verb, never a token abcd holds — so the fake +// answers the adapter's API calls directly rather than over a socket, and +// fails them the way `gh api` does: an error naming the HTTP status. +// +// It answers exactly these routes, and anything else is a 404: +// +// GET repos/{owner}/{repo} the repository: private, has_pages +// GET repos/{owner}/{repo}/pages the Pages site: build_type, cname, html_url +// POST repos/{owner}/{repo}/pages create the Pages site +// PUT repos/{owner}/{repo}/pages change its source or custom domain +package githubpagestest + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" +) + +// Route keys name a route for Fail and for the call log. +const ( + GetRepo = "GET repo" + GetPages = "GET pages" + CreatePages = "POST pages" + UpdatePages = "PUT pages" +) + +// Pages is one repository's Pages site. +type Pages struct { + BuildType string + CNAME string + // HTMLURL, when set, is the address the site reports in place of the one + // GitHub would derive from its domain or its repository. + HTMLURL string +} + +// Repo is one repository the fake holds. +type Repo struct { + Private bool + // Pages is nil while the repository has no Pages site. + Pages *Pages +} + +// Server is the fake. Its fields are the forge's state; lock Mu to read them +// while a call may be in flight. +type Server struct { + Mu sync.Mutex + Repos map[string]*Repo + // Fail makes a route answer with this error. + Fail map[string]error + // Calls logs each call as "<route key> <body>". + Calls []string +} + +// New is a fake holding one public repository, acme/widgets, with no Pages +// site yet. +func New() *Server { + return &Server{Repos: map[string]*Repo{"acme/widgets": {}}, Fail: map[string]error{}} +} + +// CallLog is a copy of the calls made so far. +func (s *Server) CallLog() []string { + s.Mu.Lock() + defer s.Mu.Unlock() + return append([]string(nil), s.Calls...) +} + +// status is the error `gh api` returns for an HTTP status. +func status(code int, text string) error { + return fmt.Errorf("exit status 1 (gh: %s (HTTP %d))", text, code) +} + +// API answers one call, shaped as the adapter's API. +func (s *Server) API(_ context.Context, method, path string, body []byte) ([]byte, error) { + s.Mu.Lock() + defer s.Mu.Unlock() + rest, ok := strings.CutPrefix(path, "repos/") + if !ok { + return nil, status(404, "Not Found") + } + parts := strings.Split(rest, "/") + key := "" + switch { + case len(parts) == 2 && method == "GET": + key = GetRepo + case len(parts) == 3 && parts[2] == "pages": + key = method + " pages" + default: + return nil, status(404, "Not Found") + } + s.Calls = append(s.Calls, strings.TrimSpace(key+" "+string(body))) + if err := s.Fail[key]; err != nil { + return nil, err + } + full := parts[0] + "/" + parts[1] + repo, ok := s.Repos[full] + if !ok { + return nil, status(404, "Not Found") + } + switch key { + case GetRepo: + return json.Marshal(map[string]any{"full_name": full, "private": repo.Private, "has_pages": repo.Pages != nil}) + case GetPages: + if repo.Pages == nil { + return nil, status(404, "Not Found") + } + host := strings.ToLower(parts[0]) + ".github.io" + addr := "https://" + host + "/" + parts[1] + "/" + switch { + case repo.Pages.HTMLURL != "": + addr = repo.Pages.HTMLURL + case repo.Pages.CNAME != "": + addr = "https://" + repo.Pages.CNAME + "/" + case strings.EqualFold(parts[1], host): + // The owner's own owner.github.io repository is served at the root. + addr = "https://" + host + "/" + } + return json.Marshal(map[string]any{"build_type": repo.Pages.BuildType, "cname": nilIfEmpty(repo.Pages.CNAME), "html_url": addr}) + case CreatePages: + if repo.Pages != nil { + return nil, status(409, "GitHub Pages is already enabled.") + } + var req struct { + BuildType string `json:"build_type"` + } + if json.Unmarshal(body, &req) != nil || req.BuildType == "" { + return nil, status(422, "Invalid request.") + } + repo.Pages = &Pages{BuildType: req.BuildType} + return []byte("{}"), nil + case UpdatePages: + if repo.Pages == nil { + return nil, status(404, "Not Found") + } + var req map[string]*string + if json.Unmarshal(body, &req) != nil { + return nil, status(422, "Invalid request.") + } + if v, ok := req["cname"]; ok && v != nil { + repo.Pages.CNAME = *v + } + if v, ok := req["build_type"]; ok && v != nil { + repo.Pages.BuildType = *v + } + return nil, nil + } + return nil, status(404, "Not Found") +} + +func nilIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/internal/adapter/hosting/hosting.go b/internal/adapter/hosting/hosting.go index 920ff3320..41b3ae61b 100644 --- a/internal/adapter/hosting/hosting.go +++ b/internal/adapter/hosting/hosting.go @@ -4,9 +4,10 @@ // 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. +// Two providers ship: cloudflare, the host abcd's own site uses, and +// github-pages, a project's own GitHub Pages, built by its release workflow +// (itd-2610091010348892). Another 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: // @@ -20,6 +21,9 @@ // // 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. +// A provider that names no credential (CredentialName is empty) acts through +// the forge as the person who invoked the verb, the way the forge stage does, +// and Connect is handed nothing. package hosting import "context" @@ -30,6 +34,38 @@ import "context" type Site struct { Name string Domain string + // Repo is the forge repository the site is built from, as owner/name, + // when the caller knows it. A provider that hosts the site on the forge + // itself acts on it; the others ignore it. + Repo string +} + +// Profile is what the verb and the workflow it writes need to know about a +// provider beyond its deploy step. +type Profile struct { + // Environment is the forge environment the deploy job runs in. + Environment string + // Permissions are the deploy job's token permissions, one `scope: level` + // each, in the order the workflow lists them. + Permissions []string + // Switched: the provider publishes only while the composition's + // hosting.publish switch is on. Setup asks for the switch, and the + // workflow publishes nothing while it is off. + Switched bool + // PublicOnly: the provider serves public repositories only, so setup + // refuses a private one before it writes anything. + PublicOnly bool + // Project: the provider publishes a project site, built from nothing but + // what abcd already writes to the repository (itd-2610091010348892): + // setup writes the switch and the workflow and no composition or site + // sources, and the workflow builds with `abcd site build --project`. + Project bool + // BasePath is the path the site is served below on the host, with a + // leading and a trailing slash; "/" is the root. + BasePath string + // Address is where the site will be served, when the provider can say so + // before the host exists; empty when it cannot. + Address string } // State is what the host holds for a site, read without writing anything. @@ -39,6 +75,10 @@ type State struct { // Routed is true when the domain is already routed to the host, and // vacuously true when the site asks for no domain. Routed bool + // Existing names the site the host already serves, by its address and + // how it is built, when the provider can say so; empty when the host + // does not exist yet. The verb names it before publishing over it. + Existing string } // Provider is the live half of the seam: one connected account. @@ -53,8 +93,10 @@ type Provider interface { 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) + // Address reports the address the site is served from, and a note on it + // when the host serves it in a state the person should know about (a + // certificate not issued yet, say); the note is empty otherwise. + Address(ctx context.Context, s Site) (addr, note string, err error) } // Adapter is one provider: the repository half as data, and Connect for the @@ -72,8 +114,11 @@ type Adapter interface { // 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. + // repo-relative, and its bytes for s; an empty path when the provider + // reads none. HostConfig(s Site) (rel string, data []byte) + // Profile is the provider's facts for s. + Profile(s Site) Profile // Connect returns a Provider acting with token. The token stays inside it. Connect(token string) Provider } diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index e9a83f3d8..ec5bb91a9 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -119,6 +119,10 @@ type DetectionResult struct { // by DryRun while the question is open (readme.offered) and omitted // otherwise (spc-2610031704338417). Readme *ReadmeView `json:"readme,omitempty"` + // Sessions is the live brief session questions as the plugin page relays + // them, carried by DryRun while they are open (sessions.offered) and + // omitted otherwise (spc-2610141709430146 step 6). + Sessions *SessionsView `json:"sessions,omitempty"` // pluginRoot is the resolved plugin root; not serialized. pluginRoot string @@ -190,6 +194,12 @@ type InstallOptions struct { // readme.parts (readme_apply.go); a value ParseReadmeParts refuses refuses // the install before any write. ReadmeParts string + // LiveSessions is --live-sessions as given: local, cloud, both or none; + // "" is the flag not given. It answers the live brief session questions + // in any run, --yes included, and is stored as sessions.live + // (sessions_ask.go); a value ParseLiveSessions refuses refuses the + // install before any write. + LiveSessions string // ApproveDependency answers the dependency category's question yes without // asking it: the front door sets it when the person named a tool to // install (--install-tool), which is that answer given in advance. Every diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index e7cdd8caf..47a3c89c3 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -110,6 +110,14 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er } } + // A --live-sessions value that is not an answer stops the run before its + // first write, naming the answers. + if opts.LiveSessions != "" { + if perr := ParseLiveSessions(opts.LiveSessions); perr != nil { + return InstallResult{Status: "refused", Notes: []string{perr.Error()}}, nil + } + } + // The yes to the after-update offer (--only-waiting): only what waits is // put, and the yes approved every kind of change. opts, waiting, err := onlyWaitingRun(abs, det, opts) @@ -173,6 +181,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er !overridesWouldChange(abs, opts.ValueOverrides) && !attributionWouldChange(abs, opts) && !readmeWouldChange(abs, opts) && + !sessionsWouldChange(abs, opts) && !outboundWouldChange(abs, opts) && !modeForced { // One state reaches here that is NOT a no-op: a config.json that could not @@ -222,22 +231,23 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er approved, declined := resolveApproval(det.Gaps, opts, overrideChanges(abs, opts.ValueOverrides), p) ac := &applyCtx{ - cwd: abs, - det: det, - approved: approved, - overrides: opts.ValueOverrides, - prompter: p, - gapPresent: gapIDSet(det.Gaps), - autoYes: opts.Yes, - devMode: opts.Dev, - modeForced: modeForced, - binTarget: binTargetPath, - attribution: opts.Attribution, - outbound: opts.Outbound, - confirmTool: opts.ConfirmTool, - provenance: opts.VerifyProvenance, - readmeFlag: opts.ReadmeParts, - waiting: waiting, + cwd: abs, + det: det, + approved: approved, + overrides: opts.ValueOverrides, + prompter: p, + gapPresent: gapIDSet(det.Gaps), + autoYes: opts.Yes, + devMode: opts.Dev, + modeForced: modeForced, + binTarget: binTargetPath, + attribution: opts.Attribution, + outbound: opts.Outbound, + confirmTool: opts.ConfirmTool, + provenance: opts.VerifyProvenance, + readmeFlag: opts.ReadmeParts, + sessionsFlag: opts.LiveSessions, + waiting: waiting, } // Ordered apply steps. @@ -263,6 +273,8 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er // After stepMarker, before stepSymlink: the README pair's place in // setup's fixed order (decision 7). ac.stepReadme() + // After the README pair: the live brief session questions. + ac.stepSessions() ac.stepSymlink() // After stepSymlink, never before: the record names the entry that step // leaves on PATH, and the hooks read it before they will run that entry. @@ -459,6 +471,7 @@ type applyCtx struct { binTarget string // the resolved PATH entry this run installs (never re-derived) confirmTool tools.Confirm readmeFlag string // --readme-parts as given; "" when not given + sessionsFlag string // --live-sessions as given; "" when not given provenance ProvenanceCheck // the build-provenance check a cache promotion must pass; nil refuses unverifiedCheck string // the hand check for an owned copy whose provenance failed; non-empty reports UnverifiedCopyGapID unverifiedWhy string // why that copy was not verified, when a fact other than the check says so (an older release than the mark) @@ -2334,16 +2347,17 @@ const credentialAtRestGapID = "history.credential_at_rest" // status-line offer (see stepStatusLine), the two model-tier routing offers // (see stepOracleRouting), the drain eligibility record (see stepDrainRule), // the retirement of a tool's own conventions file (see -// stepConventionsFiles) and the README question (see stepReadme). In the +// stepConventionsFiles), the README question (see stepReadme) and the live +// brief session question (see stepSessions). In the // order they are reported. -var optionalGapIDs = []string{OptionalPinGapID, ReadmeOfferGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var optionalGapIDs = []string{OptionalPinGapID, ReadmeOfferGapID, SessionsOfferGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} // terminalOnlyGapIDs are the optional offers put only to a person at a // terminal, and their categories: off one, neither the category nor the offer // is asked, so a piped answer stream keeps the order it had before they // existed. The README question has no category to ask (resolveApproval leaves // it out everywhere: the question is the consent). -var terminalOnlyGapIDs = []string{ReadmeOfferGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var terminalOnlyGapIDs = []string{ReadmeOfferGapID, SessionsOfferGapID, DrainRuleOfferGapID, ConventionsRetireGapID} var terminalOnlyCategories = []GapCategory{DrainRule, ConventionsFile} @@ -2377,7 +2391,7 @@ func optionalAskable(cwd string, gaps []Gap, p Prompter) []string { // answer is stored in exists (readmeStorable). func optionalAsked(cwd, id string, p Prompter) bool { switch { - case id == ReadmeOfferGapID: + case id == ReadmeOfferGapID, id == SessionsOfferGapID: return atTerminal(p) && readmeStorable(cwd) case slices.Contains(terminalOnlyGapIDs, id): return atTerminal(p) @@ -2499,8 +2513,10 @@ func resolveApproval(gaps []Gap, opts InstallOptions, flagChanges []string, p Pr lines[ConfigChange] = append(lines[ConfigChange], flagChanges...) } // The README question is its own consent (open question 5): its category - // is never asked, approved or declined. + // is never asked, approved or declined. So is the live brief session + // question. delete(present, Readme) + delete(present, Sessions) approved := map[GapCategory]bool{} switch { case opts.ApprovedCategories != nil: diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index aa04f3ac5..4a3668e1e 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -121,6 +121,7 @@ func Detect(cwd string) (DetectionResult, error) { gaps = append(gaps, detectConfigValues(abs)...) gaps = append(gaps, detectMarkerDrift(abs)...) gaps = append(gaps, detectReadme(abs)...) + gaps = append(gaps, detectSessions(abs)...) gaps = append(gaps, detectPathSymlink(abs, pluginRoot, pluginOK)...) gaps = append(gaps, detectStatusLine(harness)...) gaps = append(gaps, detectOracleRouting(abs)...) @@ -164,19 +165,27 @@ func Detect(cwd string) (DetectionResult, error) { // DryRun runs Detect and returns the envelope (Adopted=nil). Zero writes. // // While the README question is open it also carries the README view -// (readmeView), which the plugin page relays the question pair from; Detect -// alone, which the hooks and the status board call, derives no part. +// (readmeView), which the plugin page relays the question pair from, and +// while the live brief session question is open, its view (sessionsView); +// Detect alone, which the hooks and the status board call, derives no part. func DryRun(cwd string) (DetectionResult, error) { det, err := Detect(cwd) - if err != nil || !hasGapID(det.Gaps, ReadmeOfferGapID) { + if err != nil || (!hasGapID(det.Gaps, ReadmeOfferGapID) && !hasGapID(det.Gaps, SessionsOfferGapID)) { return det, err } abs, err := filepath.Abs(cwd) if err != nil { return DetectionResult{}, err } - if v, ok := readmeView(abs); ok { - det.Readme = &v + if hasGapID(det.Gaps, ReadmeOfferGapID) { + if v, ok := readmeView(abs); ok { + det.Readme = &v + } + } + if hasGapID(det.Gaps, SessionsOfferGapID) { + if v, ok := sessionsView(abs); ok { + det.Sessions = &v + } } return det, nil } diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index 1239ba6a5..921f7ae80 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -323,6 +323,11 @@ var optionalSkippedHelp = map[string]SummaryItem{ Why: "Which parts of README.md abcd keeps current, if any, is the owner's choice, so abcd writes none without an answer.", Action: "Run abcd ahoy install at a terminal, without --yes, and answer it; or, to answer it without being asked, pass --readme-parts " + ReadmePartsChoices + " to abcd ahoy install; none answers no and is not asked again.", }, + SessionsOfferGapID: { + What: "The live brief session question was not asked; live sessions are not set up.", + Why: "Whether the team edits its brief live, and where sessions may run, is the owner's choice, so abcd sets nothing up without an answer.", + Action: "Run abcd ahoy install at a terminal, without --yes, and answer it; or, to answer it without being asked, pass --live-sessions " + LiveSessionsChoices + " to abcd ahoy install; none answers no and is not asked again.", + }, ConventionsRetireGapID: { What: "An agent tool's own conventions file that only repeats AGENTS.md was left in place.", Why: "Removing a file from your project needs your own answer; until it goes, that tool reads it in place of AGENTS.md.", diff --git a/internal/core/ahoy/only_waiting.go b/internal/core/ahoy/only_waiting.go index 3fd35f394..5c04190fd 100644 --- a/internal/core/ahoy/only_waiting.go +++ b/internal/core/ahoy/only_waiting.go @@ -59,7 +59,8 @@ type waitingFilter struct { } // asks reports whether the question key is put. A README part rides on the -// README question, and a status line element on the status line offer. +// README question, a status line element on the status line offer, and where +// live sessions run on whether to set them up. func (f *waitingFilter) asks(key string) bool { if f.none || key == "" { return false @@ -69,6 +70,8 @@ func (f *waitingFilter) asks(key string) bool { key = ReadmePromptKey case strings.HasPrefix(key, elementPromptPrefix): key = StatusLineOfferGapID + case key == SessionsWherePromptKey: + key = SessionsPromptKey // where sessions run follows whether to set them up } return f.keys[key] } diff --git a/internal/core/ahoy/only_waiting_test.go b/internal/core/ahoy/only_waiting_test.go index 36d59c595..28d2900aa 100644 --- a/internal/core/ahoy/only_waiting_test.go +++ b/internal/core/ahoy/only_waiting_test.go @@ -206,3 +206,17 @@ func TestOnlyWaitingDeclinesAnOfferThatDoesNotWait(t *testing.T) { t.Fatalf("the person was put %v, want %v", inner.keys, want) } } + +// TestOnlyWaitingPutsWhereSessionsRunWithTheSessionsQuestion: the second live +// session question follows the first, so a waiting sessions question is put +// whole and one that does not wait is not put at all. +func TestOnlyWaitingPutsWhereSessionsRunWithTheSessionsQuestion(t *testing.T) { + waits := &waitingFilter{keys: map[string]bool{SessionsPromptKey: true}} + if !waits.asks(SessionsPromptKey) || !waits.asks(SessionsWherePromptKey) { + t.Fatal("a waiting sessions question was not put whole") + } + other := &waitingFilter{keys: map[string]bool{"visibility": true}} + if other.asks(SessionsPromptKey) || other.asks(SessionsWherePromptKey) { + t.Fatal("a sessions question that does not wait was put") + } +} diff --git a/internal/core/ahoy/prompt_help.go b/internal/core/ahoy/prompt_help.go index 9864d12e9..95663dd52 100644 --- a/internal/core/ahoy/prompt_help.go +++ b/internal/core/ahoy/prompt_help.go @@ -297,6 +297,9 @@ func helpFor(key string) (PromptHelp, bool) { if h, ok := readmeHelp(key); ok { return h, true } + if h, ok := sessionsHelp(key); ok { + return h, true + } if el, ok := strings.CutPrefix(key, elementPromptPrefix); ok { shows, known := statusLineElementAbout[statusline.ElementKey(el)] if !known { diff --git a/internal/core/ahoy/sessions_ask.go b/internal/core/ahoy/sessions_ask.go new file mode 100644 index 000000000..02d57bbad --- /dev/null +++ b/internal/core/ahoy/sessions_ask.go @@ -0,0 +1,315 @@ +package ahoy + +// The live brief session questions at repository setup (spc-2610141709430146 +// step 6, for itd-2610091016262335, adr-2610161400542916). +// +// The answer is `sessions.live` in .abcd/config.json: +// +// - absent: not yet answered. Detection raises the optional +// sessions.offered gap. A person at a terminal, in a run that is not +// --yes, is asked the two questions; every other run reports the gap +// under optional_skipped with the flag that answers it. `abcd session +// start` and `abcd session blueprint` ask it again at a terminal; +// - `none`: answered no. Never asked again; `abcd session setup` still +// sets live sessions up by hand; +// - `local`, `cloud` or `both`: where sessions may run, which `abcd session +// setup` reads to decide its steps. +// +// Q1, keyed sessions, asks whether to set up live brief sessions: yes asks +// Q2, no stores none, and deciding later (the default, and end of input) +// stores nothing. Q2, keyed sessions.where, asks where they may run. +// `--live-sessions local|cloud|both|none` answers both in any run, --yes +// included: it is the owner's answer itself, so the category has no approval +// question, as the README pair has none. + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/intentdriven/abcd/internal/core/question" + "github.com/intentdriven/abcd/internal/termsafe" +) + +// Sessions covers the live brief session answer. It is never asked as a +// category: the question is the consent. +const Sessions GapCategory = "sessions" + +// SessionsOfferGapID is the live-session question, raised while it is +// unanswered. +const SessionsOfferGapID = "sessions.offered" + +// SessionsPromptKey keys Q1; SessionsWherePromptKey keys Q2. +const ( + SessionsPromptKey = "sessions" + SessionsWherePromptKey = "sessions.where" +) + +// LiveSessionsNone is the stored no. +const LiveSessionsNone = "none" + +// LiveSessionsChoices is what --live-sessions takes, as a summary names it. +const LiveSessionsChoices = "local|cloud|both|none" + +// liveSessionsWhere are Q2's answers, in the order it offers them. +var liveSessionsWhere = []string{"local", "cloud", "both"} + +// sessionsChangeLater is both questions' change-later line. +const sessionsChangeLater = "abcd ahoy install --live-sessions " + LiveSessionsChoices + +// sessionsQ1Choices are Q1's answers; deciding later is its default. +var sessionsQ1Choices = []string{"yes", "no", SetupLaterValue} + +// sessionsQ2Choices are Q2's answers; deciding later is its default. +var sessionsQ2Choices = append(append([]string(nil), liveSessionsWhere...), SetupLaterValue) + +var sessionsQ1 = PromptHelp{ + Key: SessionsPromptKey, + About: "In a live brief session the team edits the brief together during a call. Setting it up " + + "registers the team's own GitHub sign-in app once, and for the cloud deploys the team's relay " + + "on Render. Set up live brief sessions?", + ChangeLater: sessionsChangeLater, + Choices: []ChoiceHelp{ + {Value: "yes", Meaning: "Asks next where sessions may run; abcd session setup then does the rest."}, + {Value: "no", Meaning: "Never asked again; abcd session setup still sets them up by hand."}, + {Value: SetupLaterValue, Meaning: "Records nothing; setup asks again, and so does abcd session start."}, + }, +} + +var sessionsQ2 = PromptHelp{ + Key: SessionsWherePromptKey, + About: "Local runs a session on the host's computer, reached only over the team's Tailscale network. " + + "Cloud runs it on the team's relay on Render, with the page on abcdesign.app. Where may sessions run?", + ChangeLater: sessionsChangeLater, + Choices: []ChoiceHelp{ + {Value: "local", Meaning: "Host's computer only; sign-in by a short code, and no secret is kept."}, + {Value: "cloud", Meaning: "Team's relay only; one-click sign-in, the secret kept on Render."}, + {Value: "both", Meaning: "Either; the sign-in app's device flow is switched on for local sessions."}, + {Value: SetupLaterValue, Meaning: "Records nothing, so the next setup run asks again."}, + }, +} + +// sessionsHelp is the help for a live-session question key. +func sessionsHelp(key string) (PromptHelp, bool) { + switch key { + case SessionsPromptKey: + return withNow(sessionsQ1), true + case SessionsWherePromptKey: + return withNow(sessionsQ2), true + } + return PromptHelp{}, false +} + +// withNow is h with its current setting: the questions are asked only while +// nothing is stored. +func withNow(h PromptHelp) PromptHelp { + h.Choices = append([]ChoiceHelp(nil), h.Choices...) + h.Now = "not set" + return h +} + +// ParseLiveSessions refuses a --live-sessions value that is not local, cloud, +// both or none. +func ParseLiveSessions(v string) error { + if v == LiveSessionsNone || slices.Contains(liveSessionsWhere, v) { + return nil + } + return fmt.Errorf("--live-sessions takes local, cloud, both or none, not %q", termsafe.Sanitize(v)) +} + +// LiveSessions reads the stored answer: local, cloud, both or none, and +// false when nothing is stored. An unreadable config, or a stored value that +// is not one of those, is an error. +func LiveSessions(cwd string) (string, bool, error) { + cfg, err := readConfig(cwd) + if err != nil { + return "", false, err + } + raw, ok := subMap(cfg, "sessions")["live"] + if !ok { + return "", false, nil + } + v, isString := raw.(string) + if !isString || ParseLiveSessions(v) != nil { + return "", false, errors.New("sessions.live in .abcd/config.json is not local, cloud, both or none; " + + "set it with abcd ahoy install --live-sessions " + LiveSessionsChoices) + } + return v, true, nil +} + +// StoreLiveSessions saves v as sessions.live under the config's lock and +// returns what was stored before ("" for nothing). It refuses a value +// ParseLiveSessions refuses, and a repository with no .abcd/config.json. +func StoreLiveSessions(cwd, v string) (string, error) { + if err := ParseLiveSessions(v); err != nil { + return "", err + } + var prev string + err := withConfigLock(cwd, func() error { + cfg, err := readConfig(cwd) + if err != nil { + return err + } + if cfg == nil { + return errors.New(".abcd/config.json does not exist yet; answer y to creating abcd's starter files and run abcd ahoy install again") + } + if s, ok := subMap(cfg, "sessions")["live"].(string); ok { + prev = s + } + setSub(cfg, "sessions", "live", v) + return writeConfig(cwd, cfg) + }) + return prev, err +} + +// detectSessions raises the live-session question while it is unanswered. A +// config abcd cannot read raises its own gap; this one waits for it, and a +// stored value that is not an answer is reported by setup, not re-asked. +func detectSessions(cwd string) []Gap { + cfg, err := readConfig(cwd) + if err != nil { + return nil + } + if _, ok := subMap(cfg, "sessions")["live"]; ok { + return nil + } + return []Gap{{ + ID: SessionsOfferGapID, Category: Sessions, Scope: "repo", + Title: "the live brief session question is not answered", + Detail: "abcd can set up live brief sessions, where the team edits the brief together during a call, " + + "on the host's own computer over Tailscale, on the team's own relay on Render, or both.", + FixHint: "answer it with abcd ahoy install --live-sessions " + LiveSessionsChoices + "; none answers no and is not asked again.", + Required: false, Resolvable: true, + }} +} + +// SessionsView is the live-session questions as detection carries them while +// they are open, for a front door that relays them: Q1 alone, then Q2 alone, +// each as the shared question type. +type SessionsView struct { + Asks []question.Ask `json:"asks"` +} + +// sessionsView is the view while the question is open, and false once it is +// answered (or the config cannot be read). +func sessionsView(cwd string) (SessionsView, bool) { + if len(detectSessions(cwd)) == 0 { + return SessionsView{}, false + } + v := SessionsView{} + for i, key := range []string{SessionsPromptKey, SessionsWherePromptKey} { + h, _ := sessionsHelp(key) + choices := sessionsQ1Choices + if key == SessionsWherePromptKey { + choices = sessionsQ2Choices + } + v.Asks = append(v.Asks, question.Safe(question.Ask{Questions: []question.Question{ + SetupHelpedQuestion(i+1, h, choices, SetupLaterValue), + }})) + } + return v, true +} + +// askSessions asks Q1 and, on a yes, Q2, through p, returning the answer to +// store, and false when the answer is to decide later. +func askSessions(p Prompter) (string, bool) { + h1, _ := sessionsHelp(SessionsPromptKey) + switch strings.ToLower(strings.TrimSpace(promptHelped(p, h1, sessionsQ1Choices, SetupLaterValue))) { + case "yes": + case "no": + return LiveSessionsNone, true + default: + return "", false + } + h2, _ := sessionsHelp(SessionsWherePromptKey) + v := strings.ToLower(strings.TrimSpace(promptHelped(p, h2, sessionsQ2Choices, SetupLaterValue))) + if slices.Contains(liveSessionsWhere, v) { + return v, true + } + return "", false +} + +// AskLiveSessions puts the open live-session question to a person at a +// terminal, as setup does, and stores the answer: the session verbs call it +// when someone tries to start a session on a project not yet set up. It asks +// nothing, and returns false, off a terminal, once the question is answered, +// and where there is no config to store the answer in. +func AskLiveSessions(cwd string, p Prompter) (string, bool, error) { + if !atTerminal(p) || !readmeStorable(cwd) { + return "", false, nil + } + if _, set, err := LiveSessions(cwd); err != nil || set { + return "", false, err + } + v, ok := askSessions(p) + if !ok { + return "", false, nil + } + if _, err := StoreLiveSessions(cwd, v); err != nil { + return "", false, err + } + return v, true, nil +} + +// sessionsWouldChange reports whether --live-sessions differs from what is +// stored, which makes an otherwise current repository do the work. +func sessionsWouldChange(cwd string, opts InstallOptions) bool { + if opts.LiveSessions == "" || ParseLiveSessions(opts.LiveSessions) != nil { + return false + } + v, set, err := LiveSessions(cwd) + return err != nil || !set || v != opts.LiveSessions +} + +// asksSessions reports whether this run puts the questions to a person. +func (a *applyCtx) asksSessions() bool { + return !a.autoYes && atTerminal(a.prompter) && a.has(SessionsOfferGapID) && readmeStorable(a.cwd) +} + +// stepSessions asks the questions while they are open, of a person at a +// terminal, and stores that answer or a --live-sessions one, echoing the +// change. It runs after stepReadme. +func (a *applyCtx) stepSessions() { + stored, set, err := LiveSessions(a.cwd) + want := a.sessionsFlag + if err != nil && want == "" { + a.refuse(err.Error()) + return + } + if want == "" && !set && a.asksSessions() { + v, ok := askSessions(a.prompter) + if !ok { + return // decided later: nothing stored, so the next run asks again + } + want = v + } + if want == "" || (set && want == stored) { + return + } + if _, serr := StoreLiveSessions(a.cwd, want); serr != nil { + a.refuse("the live brief session answer was not saved: " + errText(serr)) + return + } + from := "not set" + if set { + from = stored + } + a.echoChange("sessions.live", from, want) + a.note(writeSettings, configPath(a.cwd)) +} + +// AskSessionsWhere asks Q2 alone, of a person at a terminal: `abcd session +// setup`, run by hand, asks it where nothing says where sessions may run. It +// stores nothing; false off a terminal and on deciding later. +func AskSessionsWhere(p Prompter) (string, bool) { + if !atTerminal(p) { + return "", false + } + h, _ := sessionsHelp(SessionsWherePromptKey) + v := strings.ToLower(strings.TrimSpace(promptHelped(p, h, sessionsQ2Choices, SetupLaterValue))) + if slices.Contains(liveSessionsWhere, v) { + return v, true + } + return "", false +} diff --git a/internal/core/ahoy/sessions_ask_test.go b/internal/core/ahoy/sessions_ask_test.go new file mode 100644 index 000000000..e9166f2c3 --- /dev/null +++ b/internal/core/ahoy/sessions_ask_test.go @@ -0,0 +1,279 @@ +package ahoy + +import ( + "reflect" + "slices" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/question" +) + +// The live brief session questions at repository setup +// (spc-2610141709430146 step 6, for itd-2610091016262335). + +// sessionsKeys is the live-session questions asked, in order. +func (p *helpedPrompter) sessionsKeys() []string { + var out []string + for _, a := range p.asked { + if a.key == SessionsPromptKey || a.key == SessionsWherePromptKey { + out = append(out, a.key) + } + } + return out +} + +// storedLiveSessions is sessions.live in the repository's config. +func storedLiveSessions(t *testing.T, repo string) (string, bool) { + t.Helper() + cfg, err := readConfig(repo) + if err != nil { + t.Fatal(err) + } + v, ok := subMap(cfg, "sessions")["live"] + if !ok { + return "", false + } + s, _ := v.(string) + return s, true +} + +// TestSessionsQuestionsAskedAtTerminal: at a terminal, setup asks whether to +// set up live brief sessions, then, on a yes, where they may run; each with +// its current setting, its change-later line and deciding later. The answer +// is stored as sessions.live. +func TestSessionsQuestionsAskedAtTerminal(t *testing.T) { + for _, where := range []string{"local", "cloud", "both"} { + t.Run(where, func(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + p := &helpedPrompter{confirm: approveCategories, answers: map[string]string{ + SessionsPromptKey: "yes", SessionsWherePromptKey: where, + }} + res := terminalInstall(t, repo, p) + if got := p.sessionsKeys(); !reflect.DeepEqual(got, []string{SessionsPromptKey, SessionsWherePromptKey}) { + t.Fatalf("live-session questions asked %q", got) + } + q1 := p.find(t, SessionsPromptKey) + if !reflect.DeepEqual(q1.choices, []string{"yes", "no", SetupLaterValue}) || q1.def != SetupLaterValue { + t.Errorf("Q1 offers %q (default %q)", q1.choices, q1.def) + } + q2 := p.find(t, SessionsWherePromptKey) + if !reflect.DeepEqual(q2.choices, []string{"local", "cloud", "both", SetupLaterValue}) || q2.def != SetupLaterValue { + t.Errorf("Q2 offers %q (default %q)", q2.choices, q2.def) + } + for _, a := range []helpedAsk{q1, q2} { + if !a.helped || a.help.Now == "" || a.help.ChangeLater == "" { + t.Errorf("%s asked without its current setting or change-later line: %+v", a.key, a.help) + } + } + if !strings.HasSuffix(q1.help.About, "Set up live brief sessions?") { + t.Errorf("Q1 does not end on its question: %q", q1.help.About) + } + if !strings.HasSuffix(q2.help.About, "Where may sessions run?") { + t.Errorf("Q2 does not end on its question: %q", q2.help.About) + } + if got, ok := storedLiveSessions(t, repo); !ok || got != where { + t.Fatalf("sessions.live = %q (stored %v), want %q", got, ok, where) + } + if !containsString(res.Changes, "sessions.live: not set -> "+where) { + t.Errorf("changes = %q", res.Changes) + } + again := &helpedPrompter{confirm: approveCategories} + terminalInstall(t, repo, again) + if got := again.sessionsKeys(); len(got) != 0 { + t.Errorf("an answered question was asked again: %q", got) + } + }) + } +} + +// TestSessionsNoIsNeverAskedAgain: a no is stored as none and never asked +// again, at setup or anywhere. +func TestSessionsNoIsNeverAskedAgain(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + p := &helpedPrompter{confirm: approveCategories, answers: map[string]string{SessionsPromptKey: "no"}} + terminalInstall(t, repo, p) + if got := p.sessionsKeys(); !reflect.DeepEqual(got, []string{SessionsPromptKey}) { + t.Fatalf("asked %q, want Q1 alone", got) + } + if got, ok := storedLiveSessions(t, repo); !ok || got != LiveSessionsNone { + t.Fatalf("a no stored %q (stored %v), want none", got, ok) + } + again := &helpedPrompter{confirm: approveCategories} + res := terminalInstall(t, repo, again) + if got := again.sessionsKeys(); len(got) != 0 { + t.Errorf("a stored no was asked again: %q", got) + } + if containsString(res.OptionalSkipped, SessionsOfferGapID) { + t.Errorf("a stored no is still reported skipped: %v", res.OptionalSkipped) + } + asked := &helpedPrompter{} + if _, answered, err := AskLiveSessions(repo, asked); err != nil || answered || len(asked.asked) != 0 { + t.Errorf("AskLiveSessions after a no: answered %v, err %v, asked %q", answered, err, asked.keys()) + } +} + +// TestSessionsLaterAsksAgain: deciding later, at either question, stores +// nothing, so the next setup run asks again. +func TestSessionsLaterAsksAgain(t *testing.T) { + for _, answers := range []map[string]string{ + {SessionsPromptKey: SetupLaterValue}, + {SessionsPromptKey: "", SessionsWherePromptKey: ""}, + {SessionsPromptKey: "yes", SessionsWherePromptKey: SetupLaterValue}, + } { + setupHermetic(t) + repo := installedRepo(t) + terminalInstall(t, repo, &helpedPrompter{confirm: approveCategories, answers: answers}) + if got, ok := storedLiveSessions(t, repo); ok { + t.Fatalf("%v stored %q, want nothing", answers, got) + } + again := &helpedPrompter{confirm: approveCategories} + terminalInstall(t, repo, again) + if got := again.sessionsKeys(); !slices.Contains(got, SessionsPromptKey) { + t.Errorf("%v: the next run did not ask again: %q", answers, again.keys()) + } + } +} + +// TestSessionsOffTerminalIsReportedSkipped: off a terminal the questions are +// never asked, and the run reports them skipped, naming the flag. +func TestSessionsOffTerminalIsReportedSkipped(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if !containsString(res.OptionalSkipped, SessionsOfferGapID) { + t.Fatalf("optional_skipped = %v, want %s", res.OptionalSkipped, SessionsOfferGapID) + } + if _, ok := storedLiveSessions(t, repo); ok { + t.Fatal("an unasked question stored an answer") + } + h, ok := optionalSkippedHelp[SessionsOfferGapID] + if !ok || !strings.Contains(h.Action, "--live-sessions") { + t.Errorf("the skipped help does not name the flag: %+v", h) + } +} + +// TestSessionsFlagAnswers: --live-sessions answers in any run, --yes +// included, and is echoed as a change; an unknown value refuses the run +// before anything is written. +func TestSessionsFlagAnswers(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + opts := installOpts() + opts.LiveSessions = "local" + res, err := Install(repo, opts, RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if got, _ := storedLiveSessions(t, repo); got != "local" { + t.Fatalf("sessions.live = %q after --live-sessions local (status %s, notes %v)", got, res.Status, res.Notes) + } + if !containsString(res.Changes, "sessions.live: not set -> local") { + t.Errorf("changes = %q", res.Changes) + } + opts.LiveSessions = "none" + if _, err := Install(repo, opts, RefusingPrompter{}); err != nil { + t.Fatal(err) + } + if got, _ := storedLiveSessions(t, repo); got != "none" { + t.Fatalf("sessions.live = %q after --live-sessions none", got) + } + opts.LiveSessions = "everywhere" + res, err = Install(repo, opts, RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if res.Status != "refused" || len(res.Notes) == 0 || !strings.Contains(res.Notes[0], "local, cloud, both or none") { + t.Errorf("an unknown value: status %q notes %q", res.Status, res.Notes) + } + if got, _ := storedLiveSessions(t, repo); got != "none" { + t.Errorf("a refused value changed sessions.live to %q", got) + } +} + +// TestSessionsViewPassesTheLimits: while the question is open, detection +// carries both questions as the host's question tool takes them, each passing +// every asking limit, Q1 and Q2 in calls of their own. +func TestSessionsViewPassesTheLimits(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + det, err := DryRun(repo) + if err != nil { + t.Fatal(err) + } + if det.Sessions == nil || len(det.Sessions.Asks) != 2 { + t.Fatalf("detection carries no live-session view: %+v", det.Sessions) + } + for i, a := range det.Sessions.Asks { + if len(a.Questions) != 1 { + t.Fatalf("call %d holds %d questions", i, len(a.Questions)) + } + for _, f := range question.CheckLimits(a.Fields(), question.Default, question.Addressee{}) { + t.Errorf("call %d: %+v", i, f) + } + q := a.Questions[0] + if q.Later.Label != question.Default.LaterLabels[0] { + t.Errorf("call %d's last answer is %q", i, q.Later.Label) + } + } + if det.Sessions.Asks[0].Questions[0].ID != SessionsPromptKey || det.Sessions.Asks[1].Questions[0].ID != SessionsWherePromptKey { + t.Errorf("questions out of order: %+v", det.Sessions.Asks) + } + if err := ParseLiveSessions("both"); err != nil { + t.Fatal(err) + } + if _, err := StoreLiveSessions(repo, "cloud"); err != nil { + t.Fatal(err) + } + det, err = DryRun(repo) + if err != nil { + t.Fatal(err) + } + if det.Sessions != nil { + t.Errorf("an answered question still carries its view") + } +} + +// TestAskLiveSessions: the session verbs ask the open question of a person at +// a terminal through the same words, store the answer, and ask nothing off a +// terminal. +func TestAskLiveSessions(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + if v, answered, err := AskLiveSessions(repo, RefusingPrompter{}); err != nil || answered || v != "" { + t.Fatalf("off a terminal: %q %v %v", v, answered, err) + } + p := &helpedPrompter{answers: map[string]string{SessionsPromptKey: "yes", SessionsWherePromptKey: "both"}} + v, answered, err := AskLiveSessions(repo, p) + if err != nil || !answered || v != "both" { + t.Fatalf("at a terminal: %q %v %v (asked %q)", v, answered, err, p.keys()) + } + if got, _ := storedLiveSessions(t, repo); got != "both" { + t.Errorf("sessions.live = %q", got) + } + if got, set, err := LiveSessions(repo); err != nil || !set || got != "both" { + t.Errorf("LiveSessions = %q %v %v", got, set, err) + } +} + +// TestAskSessionsWhere: running the setup by hand asks where sessions may +// run alone, of a person at a terminal, with the setup's own words; off a +// terminal, and on deciding later, it has no answer. +func TestAskSessionsWhere(t *testing.T) { + if _, ok := AskSessionsWhere(RefusingPrompter{}); ok { + t.Fatal("answered off a terminal") + } + p := &helpedPrompter{answers: map[string]string{SessionsWherePromptKey: "cloud"}} + if v, ok := AskSessionsWhere(p); !ok || v != "cloud" || !reflect.DeepEqual(p.keys(), []string{SessionsWherePromptKey}) { + t.Fatalf("at a terminal: %q %v, asked %q", v, ok, p.keys()) + } + later := &helpedPrompter{answers: map[string]string{SessionsWherePromptKey: SetupLaterValue}} + if _, ok := AskSessionsWhere(later); ok { + t.Error("deciding later answered") + } +} diff --git a/internal/core/ahoy/waiting.go b/internal/core/ahoy/waiting.go index f60f4be64..c976e8041 100644 --- a/internal/core/ahoy/waiting.go +++ b/internal/core/ahoy/waiting.go @@ -174,6 +174,7 @@ func armedQuestions(cwd string, det DetectionResult) []WaitingItem { } byGap("identity.refounding_candidate", "history.lineage") byGap(ReadmeOfferGapID, ReadmePromptKey) + byGap(SessionsOfferGapID, SessionsPromptKey) for _, id := range []string{StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID} { byGap(id, id) } diff --git a/internal/core/ahoy/waiting_test.go b/internal/core/ahoy/waiting_test.go index 64557bfd0..18678ab9b 100644 --- a/internal/core/ahoy/waiting_test.go +++ b/internal/core/ahoy/waiting_test.go @@ -299,6 +299,9 @@ func TestEveryQuestionTheInstallAsksCanWait(t *testing.T) { if strings.HasPrefix(k, readmePartKeyPrefix) { k = ReadmePromptKey // a part follows the README question it belongs to } + if k == SessionsWherePromptKey { + k = SessionsPromptKey // where follows whether + } if !slices.Contains(can, k) { t.Errorf("the install put %q, which waiting cannot return (it returned %v)", k, can) } diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go index 60cf221d0..7b5d7fb58 100644 --- a/internal/core/launch/scaffold/scaffold.go +++ b/internal/core/launch/scaffold/scaffold.go @@ -218,6 +218,11 @@ type PlannedFile struct { Seed bool } +// SeedKeptDetail is the Detail of every seed file WriteFiles keeps: the same +// reason for each, so a renderer may leave it unsaid where another kept +// file's reason is not. +const SeedKeptDetail = "present: the repository's own, left as it is" + // 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: @@ -250,7 +255,7 @@ func WriteFiles(repoRoot string, planned []PlannedFile, confirm bool) (outcomes writeNeeded[i] = true 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"} + outcomes[i] = FileOutcome{Path: p.Path, Status: StatusKept, Detail: SeedKeptDetail} case confirm: writeNeeded[i] = true overwrite[i] = true diff --git a/internal/core/repolint/rule_site.go b/internal/core/repolint/rule_site.go index f16a2e155..293788d3d 100644 --- a/internal/core/repolint/rule_site.go +++ b/internal/core/repolint/rule_site.go @@ -56,7 +56,14 @@ func (siteGates) Eval(ctx Context) ([]Finding, error) { } defer os.RemoveAll(tmp) - res, err := site.Check(site.CheckRequest{RepoRoot: ctx.RepoRoot, OutDir: filepath.Join(tmp, "site")}) + // The route is the settings file's: a project site's settings are gated on + // the project route, as `abcd lint site --project` gates them. A settings + // file that cannot be read is left to Check, whose refusal names it. + project := false + if m, merr := site.LoadManifest(ctx.RepoRoot); merr == nil { + project = m.Project() + } + res, err := site.Check(site.CheckRequest{RepoRoot: ctx.RepoRoot, OutDir: filepath.Join(tmp, "site"), Project: project}) if err != nil { // `abcd lint site` exits 2 when it cannot run at all: a refusal, so an // error here, not one more warning. diff --git a/internal/core/repolint/rule_site_test.go b/internal/core/repolint/rule_site_test.go new file mode 100644 index 000000000..f6af4a19e --- /dev/null +++ b/internal/core/repolint/rule_site_test.go @@ -0,0 +1,43 @@ +package repolint_test + +import ( + "testing" + + "github.com/intentdriven/abcd/internal/core/repolint" +) + +// projectSiteSettings is a project site's settings file as `abcd site setup +// --provider github-pages` writes it (itd-2610091010348892). +const projectSiteSettings = `{ + "schema_version": 1, + "mode": "project", + "purpose": "The fixture's project-site settings.", + "hosting": {"provider": "github-pages", "name": "widgets", "publish": true} +} +` + +// Bare `abcd lint` gates a project site on its own route: the settings file +// names it, so the site target is not refused for a missing --project. +func TestSiteGatesGateAProjectSiteOnItsOwnRoute(t *testing.T) { + b := newFixtureRepo(t).conforming(). + file(".abcd/site.json", projectSiteSettings). + file("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n## [0.1.0] - 2026-01-06\n\n### Added\n\n- The first cut.\n"). + commit() + git(t, b.root, "remote", "add", "origin", "https://github.com/acme/widgets.git") + res := b.run() + for _, f := range res.Findings { + if f.RuleID != "site-gates" { + continue + } + if f.Severity == repolint.SeverityError { + t.Errorf("the site target refused a project site: %s", f.Message) + continue + } + t.Errorf("the project site fails a gate: %s", f.Message) + } + for _, s := range res.Skipped { + if s == "site-gates" { + t.Fatal("the site target was skipped for a repository that declares a site") + } + } +} diff --git a/internal/core/session/app.go b/internal/core/session/app.go new file mode 100644 index 000000000..2123dec0b --- /dev/null +++ b/internal/core/session/app.go @@ -0,0 +1,75 @@ +package session + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "regexp" +) + +// AppFile is the file, under BlueprintDir, naming the team's GitHub App's +// public client id: `{"client_id": "…"}`. Repository setup writes it; a local +// session (`abcd session start`) reads it, and offers GitHub's device flow, +// which needs the client id and no secret (adr-2610161400542916). It holds +// nothing secret, and a file that carries a client_secret is refused, so a +// secret is never committed beside it. +const AppFile = "github-app.json" + +// maxAppFile bounds the file's size. +const maxAppFile = 64 << 10 + +// clientIDRe is the shape the page and the relay take for a client id. +var clientIDRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,100}$`) + +// ValidClientID reports whether s is a client id the relay passes on. +func ValidClientID(s string) bool { return clientIDRe.MatchString(s) } + +// ReadAppClientID reads the client id AppFile names in the checkout at root. +// No file is no client id and no error. The file is read through the +// checkout's root, so a symlink cannot lead it elsewhere. +func ReadAppClientID(root string) (string, error) { + r, err := os.OpenRoot(root) + if err != nil { + return "", err + } + defer r.Close() + name := path.Join(BlueprintDir, AppFile) + info, err := r.Lstat(name) + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("%s is not a regular file", name) + } + f, err := r.Open(name) + if err != nil { + return "", err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, maxAppFile+1)) + if err != nil { + return "", err + } + if len(raw) > maxAppFile { + return "", fmt.Errorf("%s is larger than %d bytes", name, maxAppFile) + } + var app map[string]json.RawMessage + if err := json.Unmarshal(raw, &app); err != nil { + return "", fmt.Errorf("%s is not a JSON object", name) + } + if _, ok := app["client_secret"]; ok { + return "", fmt.Errorf("%s holds a client_secret, which never belongs in the repository; remove it", name) + } + var id string + if err := json.Unmarshal(app["client_id"], &id); err != nil || !ValidClientID(id) { + return "", fmt.Errorf("%s names no client_id a GitHub App could have", name) + } + return id, nil +} diff --git a/internal/core/session/app_test.go b/internal/core/session/app_test.go new file mode 100644 index 000000000..51e196ac4 --- /dev/null +++ b/internal/core/session/app_test.go @@ -0,0 +1,63 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeAppFile(t *testing.T, root, body string) { + t.Helper() + dir := filepath.Join(root, ".abcd", "relay") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, AppFile), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestReadAppClientID: a local session reads its GitHub App's public client +// id from .abcd/relay/github-app.json, which repository setup writes; no file +// is no client id, and a file that is not exactly that is refused. +func TestReadAppClientID(t *testing.T) { + root := t.TempDir() + if id, err := ReadAppClientID(root); id != "" || err != nil { + t.Fatalf("no file: %q %v, want none and no error", id, err) + } + writeAppFile(t, root, `{"client_id": "Iv23liExample01", "note": "written by abcd"}`) + if id, err := ReadAppClientID(root); id != "Iv23liExample01" || err != nil { + t.Fatalf("a client id: %q %v", id, err) + } + for _, bad := range []string{ + `not json`, + `{"client_id": 7}`, + `{"client_id": ""}`, + `{"client_id": "has space"}`, + `{"client_id": "` + strings.Repeat("a", 101) + `"}`, + `{"client_id": "Iv1.ok", "client_secret": "never here"}`, + } { + writeAppFile(t, root, bad) + if id, err := ReadAppClientID(root); err == nil { + t.Errorf("%s: %q accepted", bad, id) + } + } + writeAppFile(t, root, `{"client_id": "`+strings.Repeat("a", 70<<10)+`"}`) + if _, err := ReadAppClientID(root); err == nil { + t.Error("an oversized file was read") + } + // A symlink is not followed out of the checkout. + outside := filepath.Join(t.TempDir(), "app.json") + if err := os.WriteFile(outside, []byte(`{"client_id": "Iv1.elsewhere"}`), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, ".abcd", "relay", AppFile) + _ = os.Remove(link) + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + if id, err := ReadAppClientID(root); err == nil { + t.Errorf("a symlink was followed: %q", id) + } +} diff --git a/internal/core/session/blueprint.go b/internal/core/session/blueprint.go new file mode 100644 index 000000000..7b9541b9e --- /dev/null +++ b/internal/core/session/blueprint.go @@ -0,0 +1,620 @@ +package session + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "regexp" + "strconv" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// The blueprint (spc-2610141709430146 step 2): `abcd session blueprint` writes +// a Render Blueprint and a Dockerfile, so a team deploys its own relay once to +// its own account. The Dockerfile downloads the released abcd binary for +// linux/amd64 and checks it against the digest that release's checksums.txt +// published, fetched and pinned when the files are written. +// +// The Blueprint is render.yaml at the repository's root (step 6). Render's +// Deploy button takes only the repository's URL, with an optional +// /tree/<branch>, and documents no parameter naming another Blueprint path +// (https://render.com/docs/deploy-to-render); Render reads the Blueprint "at +// your repository's root" by default, and only its New Blueprint form takes a +// custom Blueprint Path (https://render.com/docs/infrastructure-as-code). So +// the one-click deploy needs the root file. A root render.yaml the team wrote +// for itself is never touched: the Blueprint is then written under +// .abcd/relay/, to be picked by its Blueprint Path on that form. + +// ReposEnv is the environment variable `abcd session serve` reads the +// repositories it serves from, as a comma list; the blueprint sets it. +const ReposEnv = "ABCD_SESSION_REPOS" + +// OriginsEnv is the environment variable `abcd session serve` reads the page +// origins it admits from, as a comma list, when no --origin is given; the +// blueprint sets it, to DefaultPageOrigin unless told others. +const OriginsEnv = "ABCD_SESSION_ORIGINS" + +// DefaultPageOrigin is the session page's own origin, the one the blueprint's +// relay admits unless it is told others. +const DefaultPageOrigin = "https://abcdesign.app" + +// RelaysFile is the blueprint file naming the relays this repository trusts, +// written only when the verb is given the relay's origin. The session page +// reads it from the repository as its trust anchor for relays. +const RelaysFile = "relays.json" + +// BlueprintDir is where the blueprint's other files are written, relative to +// the checkout. +const BlueprintDir = ".abcd/relay" + +// RootBlueprint is the Blueprint's path when the root is free: the one +// Render's Deploy button reads. +const RootBlueprint = "render.yaml" + +// renderDeploy is Render's Deploy button's target; the repository's URL +// follows it. +const renderDeploy = "https://render.com/deploy?repo=" + +// renderDeployURL is the Deploy button's link for the repository owner/name +// on github.com (https://render.com/docs/deploy-to-render). +func renderDeployURL(repo string) string { return renderDeploy + "https://github.com/" + repo } + +// relayAsset is the release asset the relay runs: Render builds and runs on +// linux/amd64. +const relayAsset = "abcd-linux-amd64" + +// alpineDigest pins the relay image's base, alpine:3.22, to one multi-arch +// image index, so a tag moved on the registry cannot change what the relay is +// built on. Taken on 2026-10-14 from Docker Hub's registry API (the +// Docker-Content-Digest of library/alpine's 3.22 manifest, an OCI image +// index). Renew it with `docker buildx imagetools inspect alpine:3.22` when +// Alpine publishes a fix the relay needs. +const alpineDigest = "sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8" + +// releaseOrigin is where abcd's releases are published. +const releaseOrigin = "https://github.com/intentdriven/abcd" + +// releaseTagRe is the release tag a blueprint may pin: vMAJOR.MINOR.PATCH with +// an optional pre-release. The tag travels into a URL and a Dockerfile, so +// nothing else is accepted. +var releaseTagRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.]+)?$`) + +// ValidReleaseTag reports whether tag is one a blueprint may pin. +func ValidReleaseTag(tag string) bool { return releaseTagRe.MatchString(tag) } + +// firstSessionRelease is the first abcd release that carries `abcd session`: +// the relay container runs `abcd session serve`, so a blueprint pinning an +// older release builds a container that exits at once. No release carries the +// verb yet (v0.14.1 is the newest), so this is the next release it can ship +// in as planned; THE RELEASE CUT THAT FIRST SHIPS THE VERB SETS IT to that +// release's tag. A release the running abcd is counts as carrying the verb +// whatever this says, so a binary is never refused its own version. +const firstSessionRelease = "v0.17.0" + +// releaseCore is a release tag's MAJOR, MINOR and PATCH, its pre-release +// ignored: a release candidate of the first release carrying the verb carries +// it too. +func releaseCore(tag string) [3]int { + var v [3]int + core, _, _ := strings.Cut(strings.TrimPrefix(tag, "v"), "-") + for i, part := range strings.SplitN(core, ".", 3) { + v[i], _ = strconv.Atoi(part) + } + return v +} + +// releaseBefore reports whether release a is older than release b, by their +// MAJOR.MINOR.PATCH. +func releaseBefore(a, b string) bool { + x, y := releaseCore(a), releaseCore(b) + for i := range x { + if x[i] != y[i] { + return x[i] < y[i] + } + } + return false +} + +// carriesSession reports whether release carries `abcd session`: it is no +// older than firstSessionRelease, or than running, the running abcd's own +// version when that is a release. +func carriesSession(release, running string) bool { + floor := firstSessionRelease + if ValidReleaseTag(running) && releaseBefore(running, floor) { + floor = running + } + return !releaseBefore(release, floor) +} + +// AssetFetcher fetches one named asset of a release; found is false, with no +// error, when the release or the asset does not exist. +type AssetFetcher interface { + FetchReleaseAsset(tag, name string) (data []byte, url string, found bool, err error) +} + +// BlueprintRequest names the checkout and the release to pin. +type BlueprintRequest struct { + // Root is the checkout the blueprint is written into. + Root string + // Release is the abcd release tag the relay runs. + Release string + // Running is the running abcd's own version: a release it names carries + // `abcd session`, whatever firstSessionRelease says. + Running string + // Fetch reads the release's checksums.txt. + Fetch AssetFetcher + // Repos are the repositories the relay serves sessions for, each + // owner/name: the checkout's own, as the verb reads it from its origin. + Repos []string + // RelayOrigin is the deployed relay's https origin; given, relays.json + // names it, and absent, no relays.json is written. + RelayOrigin string + // Origins are the page origins the relay admits, each an https origin + // followed by the page's base path when it has one (NormalizePageOrigin); + // none is DefaultPageOrigin. + Origins []string + // ClientID is the team's GitHub App's public client id, which repository + // setup records; given, render.yaml carries it as a plain value, so + // Render's form asks only for the secret. + ClientID string +} + +// The states a blueprint file can be left in. +const ( + BlueprintWritten = "written" + BlueprintReplaced = "replaced" + BlueprintUnchanged = "unchanged" +) + +// BlueprintFile is one file the verb wrote or left. +type BlueprintFile struct { + Path string `json:"path"` + State string `json:"state"` +} + +// BlueprintResult is what the verb did. +type BlueprintResult struct { + Release string `json:"release"` + Asset string `json:"asset"` + Digest string `json:"sha256"` + Source string `json:"checksums_url"` + Repos []string `json:"repos"` + Origins []string `json:"origins"` + Files []BlueprintFile `json:"files"` + // BlueprintPath is where render.yaml was written: RootBlueprint, or + // under BlueprintDir beside a root render.yaml the team wrote. + BlueprintPath string `json:"blueprint_path"` + // DeployButton reports that Render's Deploy button deploys it, which it + // does only from the root; DeployURL is then the button's link. + DeployButton bool `json:"deploy_button"` + DeployURL string `json:"deploy_url,omitempty"` + // Callback is the callback URL the team's GitHub App must list for the + // relay's web sign-in, given a relay origin: RelayCallbackURL. + Callback string `json:"callback_url,omitempty"` +} + +// BlueprintRefusal is the refusal to overwrite a file someone edited, or one +// abcd did not write. Nothing is written when it is returned. +type BlueprintRefusal struct { + Edited []string +} + +func (r *BlueprintRefusal) Error() string { + return "refusing to overwrite " + strings.Join(r.Edited, " and ") + + ": edited since abcd wrote it, or not written by abcd; move it aside or delete it to write the blueprint again (nothing written)" +} + +// blueprintFiles is the order the files are written and reported in. +var blueprintFiles = []string{"Dockerfile", "render.yaml"} + +// stamped reports whether data carries abcd's blueprint stamp, edited since +// or not: a file without one is someone else's. +func stamped(data []byte) bool { + line, rest, _ := bytes.Cut(data, []byte("\n")) + if string(line) == "{" { + line, _, _ = bytes.Cut(rest, []byte("\n")) + return jsonHeaderRe.Match(line) + } + return headerRe.Match(line) +} + +// headerRe reads a blueprint file's first line: the release it pins and the +// digest of everything after that line, as written. jsonHeaderRe is the same +// stamp in relays.json, which holds no comment: the second line, after the +// opening brace, a key the page ignores, over everything after it. +var ( + headerRe = regexp.MustCompile(`^# abcd session blueprint (\S+) sha256:([0-9a-f]{64})$`) + jsonHeaderRe = regexp.MustCompile(`^ "abcd_session_blueprint": "(\S+) sha256:([0-9a-f]{64})",$`) +) + +// stamp prefixes body with the header line that lets a later run tell an +// untouched file from an edited one. +func stamp(release string, body []byte) []byte { + sum := sha256.Sum256(body) + return append([]byte(fmt.Sprintf("# abcd session blueprint %s sha256:%s\n", release, hex.EncodeToString(sum[:]))), body...) +} + +// stampJSON is stamp for relays.json: body is the object's members after +// the opening brace, and the stamp is the first of them. +func stampJSON(release string, body []byte) []byte { + sum := sha256.Sum256(body) + return append([]byte(fmt.Sprintf("{\n \"abcd_session_blueprint\": \"%s sha256:%s\",\n", release, hex.EncodeToString(sum[:]))), body...) +} + +// untouched reports whether data is a blueprint file exactly as abcd wrote it. +func untouched(data []byte) bool { + line, body, ok := bytes.Cut(data, []byte("\n")) + if !ok { + return false + } + re := headerRe + if string(line) == "{" { + if line, body, ok = bytes.Cut(body, []byte("\n")); !ok { + return false + } + re = jsonHeaderRe + } + m := re.FindSubmatch(line) + if m == nil { + return false + } + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) == string(m[2]) +} + +// relayHostRe is a relay origin's host: DNS labels of letters, digits and +// inner hyphens, with an optional port. +var relayHostRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*(?::[0-9]{1,5})?$`) + +// NormalizeRelayOrigin reads a relay's address as an exact https origin, +// lower-cased: https://host[:port] with nothing after it but an optional +// slash. Anything else is refused, since the page trusts the relay it names. +// The scheme's default port is dropped, as a browser's origin drops it (the +// URL standard's serialization of an origin omits a port that is the +// scheme's default, https://url.spec.whatwg.org/#origin): the page sends +// the relay's callback as its URL.origin, and GitHub matches a redirect_uri +// against the registered callback exactly, so the callback named here must +// be spelled the same way. +func NormalizeRelayOrigin(s string) (string, error) { + u, err := url.Parse(strings.TrimSpace(s)) + if err != nil || u.Scheme != "https" || u.User != nil || u.Opaque != "" || + (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || + !relayHostRe.MatchString(strings.ToLower(u.Host)) { + return "", fmt.Errorf("%q is not an https origin: give https://host[:port], with no path", s) + } + return "https://" + withoutDefaultPort(u.Scheme, strings.ToLower(u.Host)), nil +} + +// withoutDefaultPort is host (host or host:port) without the port when it is +// scheme's default: 443 for https, 80 for http. +func withoutDefaultPort(scheme, host string) string { + switch { + case scheme == "https" && strings.HasSuffix(host, ":443"): + return strings.TrimSuffix(host, ":443") + case scheme == "http" && strings.HasSuffix(host, ":80"): + return strings.TrimSuffix(host, ":80") + } + return host +} + +// pageSegmentRe is one plain path segment of a page's base path: the URL's +// unreserved characters, the rule the site builder's base path follows +// (site.NormaliseBasePath), which `abcd session page --base-path` applies. +var pageSegmentRe = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) + +// NormalizePageOrigin reads a session page's address as the relay admits it: +// an https origin, as NormalizeRelayOrigin reads it, then the page's base +// path when it is served below one, as plain path segments with one trailing +// slash (https://host/abcdesign/). The relay's web sign-in forwards a code +// only to a session page under exactly that path on that origin, so the +// base path is part of the entry rather than left open. +func NormalizePageOrigin(s string) (string, error) { + refuse := fmt.Errorf("%q is not a session page's https origin: give https://host[:port], then the page's base path if it has one, as plain path segments", s) + u, err := url.Parse(strings.TrimSpace(s)) + if err != nil || u.RawPath != "" { + return "", refuse + } + p := u.Path + u.Path = "" + origin, err := NormalizeRelayOrigin(u.String()) + if err != nil { + return "", refuse + } + if p == "" || p == "/" { + return origin, nil + } + base := strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/") + for _, seg := range strings.Split(base, "/") { + if seg == "." || seg == ".." || !pageSegmentRe.MatchString(seg) { + return "", refuse + } + } + return origin + "/" + base + "/", nil +} + +// renderRelays renders relays.json, naming origin as the one relay. +func renderRelays(release, origin string) []byte { + quoted, _ := json.Marshal(origin) + return stampJSON(release, []byte(" \"relays\": [\n "+string(quoted)+"\n ]\n}\n")) +} + +// pageOrigins is the page origins a blueprint names: those given, each +// normalized by NormalizePageOrigin, base path and all, or +// DefaultPageOrigin. +func pageOrigins(given []string) ([]string, error) { + if len(given) == 0 { + return []string{DefaultPageOrigin}, nil + } + out := make([]string, 0, len(given)) + for _, o := range given { + n, err := NormalizePageOrigin(o) + if err != nil { + return nil, err + } + out = append(out, n) + } + return out, nil +} + +// renderBlueprint renders the two files for release, pinned to digest, the +// SHA-256 of release's abcd-linux-amd64, serving repos (each owner/name) and +// admitting pages from origins (each as NormalizePageOrigin reads it; none +// is DefaultPageOrigin), so nothing in either needs escaping +// inside the quoted list. It reads and writes nothing. +func renderBlueprint(release, digest string, repos, origins []string) map[string][]byte { + return renderBlueprintAt(release, digest, repos, origins, "", RootBlueprint) +} + +// renderBlueprintAt is renderBlueprint with the team's client id (a plain +// value when given, which ValidClientID has checked, else asked for by +// Render's form) and the path render.yaml is written at, which its comment +// names the way to deploy from. +func renderBlueprintAt(release, digest string, repos, origins []string, clientID, at string) map[string][]byte { + if len(origins) == 0 { + origins = []string{DefaultPageOrigin} + } + docker := `# The team's own relay for abcd's live brief sessions. It downloads the +# released abcd binary for linux/amd64, checks it against the checksum that +# release published (pinned below when this file was written), and runs the +# relay behind the host's proxy. abcd refuses to overwrite this file once it is +# edited; delete it and run ` + "`abcd session blueprint`" + ` to write it again. +FROM --platform=linux/amd64 alpine:3.22@` + alpineDigest + ` +RUN wget -q -O /usr/local/bin/abcd "` + releaseOrigin + `/releases/download/` + release + `/` + relayAsset + `" \ + && echo "` + digest + ` /usr/local/bin/abcd" | sha256sum -c - \ + && chmod 0755 /usr/local/bin/abcd \ + && adduser -D -H relay +USER relay +ENV PORT=10000 +EXPOSE 10000 +CMD ["sh", "-c", "exec abcd session serve --listen \"0.0.0.0:${PORT}\" --behind-proxy"] +` + how := `# Dockerfile under .abcd/relay/. Deploy it with Render's Deploy button, +# ` + renderDeployURL(repos[0]) + `, +# or on Render choose New, then Blueprint, and pick this repository.` + if at != RootBlueprint { + how = `# Dockerfile beside this file. On Render, choose New, then Blueprint, pick this +# repository and set the Blueprint Path to ` + at + `.` + } + clientEnv := ` - key: ABCD_SESSION_GITHUB_CLIENT_ID + sync: false` + if clientID != "" { + clientEnv = ` - key: ABCD_SESSION_GITHUB_CLIENT_ID + value: "` + clientID + `"` + } + yaml := `# The team's own brief session relay: one free web service built from the +` + how + ` +# One deploy serves all of the team's sessions. A session lives in the relay's +# memory only, so a redeploy ends every open session; that is why autoDeploy +# is off. abcd refuses to overwrite this file once it is edited. +services: + - type: web + name: abcd-session-relay + runtime: docker + plan: free + dockerfilePath: ./.abcd/relay/Dockerfile + dockerContext: ./.abcd/relay + healthCheckPath: /healthz + autoDeploy: false + envVars: + # The team's own GitHub App, the only kind of sign-in app the relay + # takes. Render's form asks for the secret when the relay is created; + # it is never in git and stays on the relay. The client id is public. +` + clientEnv + ` + - key: ABCD_SESSION_GITHUB_CLIENT_SECRET + sync: false + # The origins of the session pages this relay admits, as a comma + # list of https origins: abcdesign.app unless the blueprint was told + # others. An entry for a page served below a base path names it + # (https://host/base/), and the sign-in returns only to a page under + # exactly that path. It is not a secret. + - key: ` + OriginsEnv + ` + value: "` + strings.Join(origins, ",") + `" + # The repositories this relay serves sessions for, read from the + # checkout's origin when this file was written: a comma list of + # owner/name, or of owners for all of an owner's repositories. It is + # not a secret. Listening beyond loopback, the relay refuses to start + # without it. + - key: ` + ReposEnv + ` + value: "` + strings.Join(repos, ",") + `" +` + return map[string][]byte{ + "Dockerfile": stamp(release, []byte(docker)), + "render.yaml": stamp(release, []byte(yaml)), + } +} + +// maxBlueprintRead caps the read of an existing blueprint file. +const maxBlueprintRead = 1 << 20 + +// WriteBlueprint fetches the release's checksum, renders the blueprint and +// writes it into the checkout. It refuses, writing nothing, when the release +// tag is not one, when the release publishes no checksum for the linux/amd64 +// binary, and when either file exists in a form abcd did not write. +func WriteBlueprint(req BlueprintRequest) (BlueprintResult, error) { + res := BlueprintResult{Release: req.Release, Asset: relayAsset, Repos: req.Repos} + if !ValidReleaseTag(req.Release) { + return res, fmt.Errorf("%q is not a release tag (vMAJOR.MINOR.PATCH)", req.Release) + } + if !carriesSession(req.Release, req.Running) { + return res, fmt.Errorf("release %s predates `abcd session`, which the relay container runs; name %s or later", req.Release, firstSessionRelease) + } + if len(req.Repos) == 0 { + return res, errors.New("the relay must serve at least one repository") + } + for _, r := range req.Repos { + if !ValidRepo(r) { + return res, fmt.Errorf("%q is not an owner/name repository", r) + } + } + origins, err := pageOrigins(req.Origins) + if err != nil { + return res, err + } + res.Origins = origins + relayOrigin := "" + if req.RelayOrigin != "" { + o, err := NormalizeRelayOrigin(req.RelayOrigin) + if err != nil { + return res, err + } + relayOrigin = o + } + if req.ClientID != "" && !ValidClientID(req.ClientID) { + return res, fmt.Errorf("%q is not a GitHub App's client id", req.ClientID) + } + if req.Fetch == nil { + return res, errors.New("no way to read the release's checksums") + } + raw, url, found, err := req.Fetch.FetchReleaseAsset(req.Release, "checksums.txt") + res.Source = url + if err != nil { + return res, fmt.Errorf("reading %s's checksums.txt: %w", req.Release, err) + } + if !found { + return res, fmt.Errorf("release %s publishes no checksums.txt at %s", req.Release, url) + } + sums, err := parseChecksums(raw) + if err != nil { + return res, fmt.Errorf("%s's checksums.txt: %w", req.Release, err) + } + digest, ok := sums[relayAsset] + if !ok { + return res, fmt.Errorf("%s's checksums.txt names no %s", req.Release, relayAsset) + } + res.Digest = digest + if !fsutil.IsRealDir(req.Root) { + return res, fmt.Errorf("%s is not a directory", req.Root) + } + root, err := os.OpenRoot(req.Root) + if err != nil { + return res, err + } + defer root.Close() + // The Blueprint goes to the root, where the Deploy button reads it, + // unless the root holds a render.yaml the team wrote. + at := RootBlueprint + switch have, err := fsutil.ReadGuardedInRoot(root, RootBlueprint, maxBlueprintRead); { + case errors.Is(err, os.ErrNotExist): + case err != nil: + return res, fmt.Errorf("reading %s: %w", RootBlueprint, err) + case !stamped(have): + at = BlueprintDir + "/" + RootBlueprint + } + res.BlueprintPath = at + if at == RootBlueprint { + res.DeployButton, res.DeployURL = true, renderDeployURL(req.Repos[0]) + } + byName := renderBlueprintAt(req.Release, digest, req.Repos, origins, req.ClientID, at) + paths := map[string]string{"Dockerfile": BlueprintDir + "/Dockerfile", "render.yaml": at} + files := map[string][]byte{} + var names []string + for _, name := range blueprintFiles { + files[paths[name]] = byName[name] + names = append(names, paths[name]) + } + if relayOrigin != "" { + rel := BlueprintDir + "/" + RelaysFile + files[rel] = renderRelays(req.Release, relayOrigin) + names = append(names, rel) + } + + // Judge every file before writing any, so a refusal writes nothing. + ok, err = fsutil.ProbeRealDirAll(req.Root, BlueprintDir) + if err != nil { + return res, fmt.Errorf("%s/ is not a plain directory: %w", BlueprintDir, err) + } + states := map[string]string{} + var edited []string + for _, rel := range names { + if !ok && strings.HasPrefix(rel, BlueprintDir+"/") { + states[rel] = BlueprintWritten + continue + } + have, err := fsutil.ReadGuardedInRoot(root, rel, maxBlueprintRead) + switch { + case errors.Is(err, os.ErrNotExist): + states[rel] = BlueprintWritten + case err != nil: + return res, fmt.Errorf("reading %s: %w", rel, err) + case bytes.Equal(have, files[rel]): + states[rel] = BlueprintUnchanged + case untouched(have): + states[rel] = BlueprintReplaced + default: + edited = append(edited, rel) + } + } + if len(edited) > 0 { + return res, &BlueprintRefusal{Edited: edited} + } + if err := fsutil.EnsureRealDirAll(req.Root, BlueprintDir, 0o755); err != nil { + return res, fmt.Errorf("creating %s/: %w", BlueprintDir, err) + } + for _, rel := range names { + state := states[rel] + if state == "" { + state = BlueprintWritten + } + if state != BlueprintUnchanged { + if err := fsutil.WriteFileAtomicInRoot(root, rel, files[rel], 0o644); err != nil { + return res, fmt.Errorf("writing %s: %w", rel, err) + } + } + res.Files = append(res.Files, BlueprintFile{Path: rel, State: state}) + } + if relayOrigin != "" { + res.Callback = RelayCallbackURL(relayOrigin) + } + return res, nil +} + +// parseChecksums reads a sha256sum manifest ("<64 hex> <name>" per line, a +// '*' binary marker tolerated). A malformed line refuses the whole manifest. +func parseChecksums(raw []byte) (map[string]string, error) { + sums := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + f := strings.Fields(line) + if len(f) != 2 || len(f[0]) != 64 { + return nil, errors.New("a malformed line") + } + if _, err := hex.DecodeString(f[0]); err != nil { + return nil, errors.New("a malformed digest") + } + sums[strings.TrimPrefix(f[1], "*")] = strings.ToLower(f[0]) + } + if len(sums) == 0 { + return nil, errors.New("no assets") + } + return sums, nil +} diff --git a/internal/core/session/blueprint_test.go b/internal/core/session/blueprint_test.go new file mode 100644 index 000000000..e9206d6f9 --- /dev/null +++ b/internal/core/session/blueprint_test.go @@ -0,0 +1,446 @@ +package session + +import ( + "encoding/json" + "errors" + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var updateBlueprint = flag.Bool("update-blueprint", false, "rewrite the blueprint goldens under testdata from this run's output") + +// testDigest is the checksum the fake release publishes for the linux/amd64 +// binary. +const testDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +// fakeRelease is a release's assets, served without a network. +type fakeRelease struct { + checksums string + found bool + err error + asked []string +} + +func (f *fakeRelease) FetchReleaseAsset(tag, name string) ([]byte, string, bool, error) { + f.asked = append(f.asked, tag+"/"+name) + url := "https://github.com/intentdriven/abcd/releases/download/" + tag + "/" + name + if f.err != nil { + return nil, url, false, f.err + } + if !f.found { + return nil, url, false, nil + } + return []byte(f.checksums), url, true, nil +} + +// testRepos is the repository the test blueprints serve. +var testRepos = []string{"acme/brief"} + +func goodRelease() *fakeRelease { + return &fakeRelease{found: true, checksums: "" + + "1111111111111111111111111111111111111111111111111111111111111111 abcd-darwin-arm64\n" + + testDigest + " abcd-linux-amd64\n" + + "2222222222222222222222222222222222222222222222222222222222222222 abcd-linux-arm64\n"} +} + +func golden(t *testing.T, name string, got []byte) { + t.Helper() + path := filepath.Join("testdata", "blueprint", name) + if *updateBlueprint { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run with -update-blueprint to write it)", err) + } + if string(got) != string(want) { + t.Fatalf("%s differs from its golden:\n--- got\n%s\n--- want\n%s", name, got, want) + } +} + +func TestBlueprintMatchesItsGoldens(t *testing.T) { + root := t.TempDir() + rel := goodRelease() + res, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.1", Fetch: rel, Repos: testRepos}) + if err != nil { + t.Fatal(err) + } + if res.Digest != testDigest || res.Asset != "abcd-linux-amd64" || !strings.HasSuffix(res.Source, "/v0.17.1/checksums.txt") { + t.Fatalf("result = %+v", res) + } + if strings.Join(rel.asked, ",") != "v0.17.1/checksums.txt" { + t.Fatalf("fetched %v, want the release's checksums alone", rel.asked) + } + for f, at := range map[string]string{"Dockerfile": filepath.Join(root, ".abcd", "relay", "Dockerfile"), "render.yaml": filepath.Join(root, "render.yaml")} { + data, err := os.ReadFile(at) + if err != nil { + t.Fatal(err) + } + golden(t, f+".golden", data) + } + if res.BlueprintPath != "render.yaml" || !res.DeployButton { + t.Fatalf("the Blueprint is not where Render's Deploy button reads it: %+v", res) + } + for _, f := range res.Files { + if f.State != "written" { + t.Fatalf("a first write reported %+v", f) + } + } +} + +func TestBlueprintPinsTheReleaseAndItsChecksum(t *testing.T) { + files := renderBlueprint("v0.17.1", testDigest, testRepos, nil) + docker := string(files["Dockerfile"]) + for _, want := range []string{ + "https://github.com/intentdriven/abcd/releases/download/v0.17.1/abcd-linux-amd64", + testDigest + " /usr/local/bin/abcd\" | sha256sum -c -", + "session serve --listen \\\"0.0.0.0:${PORT}\\\" --behind-proxy", + "--platform=linux/amd64", + } { + if !strings.Contains(docker, want) { + t.Errorf("Dockerfile lacks %q", want) + } + } + if strings.Contains(docker, "ARG ") { + t.Error("the pin is a build argument, which a build could override") + } + // The base image is pinned by its multi-arch index digest, so a tag moved + // on the registry cannot change what the relay is built on. + if !regexp.MustCompile(`(?m)^FROM --platform=linux/amd64 alpine:3\.22@sha256:[0-9a-f]{64}$`).MatchString(docker) { + t.Errorf("the base image is not pinned by digest:\n%s", docker) + } + if !strings.Contains(docker, "alpine:3.22@"+alpineDigest) { + t.Errorf("the Dockerfile does not pin %s", alpineDigest) + } + yaml := string(files["render.yaml"]) + for _, want := range []string{ + "runtime: docker", "plan: free", "dockerfilePath: ./.abcd/relay/Dockerfile", "healthCheckPath: /healthz", + "- key: ABCD_SESSION_GITHUB_CLIENT_SECRET\n sync: false", + "- key: ABCD_SESSION_REPOS\n value: \"acme/brief\"", + } { + if !strings.Contains(yaml, want) { + t.Errorf("render.yaml lacks %q", want) + } + } + if strings.Count(yaml, "- type: web") != 1 { + t.Error("render.yaml declares other than one web service") + } + if strings.Contains(strings.ToLower(yaml), "funnel") || strings.Contains(strings.ToLower(docker), "funnel") { + t.Error("the blueprint mentions Funnel") + } +} + +func TestBlueprintRefusesToOverwriteAnEditedFile(t *testing.T) { + root := t.TempDir() + if _, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.0", Fetch: goodRelease(), Repos: testRepos}); err != nil { + t.Fatal(err) + } + // Unedited: the same release is unchanged, a new release replaces it. + res, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.0", Fetch: goodRelease(), Repos: testRepos}) + if err != nil || res.Files[0].State != "unchanged" || res.Files[1].State != "unchanged" { + t.Fatalf("rerun: %+v %v, want both unchanged", res, err) + } + res, err = WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos}) + if err != nil || res.Files[0].State != "replaced" { + t.Fatalf("new release: %+v %v, want replaced", res, err) + } + + // Edited: refused, and neither file is touched. + docker := filepath.Join(root, ".abcd", "relay", "Dockerfile") + yaml := filepath.Join(root, "render.yaml") + edited := []byte(strings.Replace(string(mustRead(t, yaml)), "plan: free", "plan: starter", 1)) + if err := os.WriteFile(yaml, edited, 0o644); err != nil { + t.Fatal(err) + } + before := mustRead(t, docker) + _, err = WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.2", Fetch: goodRelease(), Repos: testRepos}) + var ref *BlueprintRefusal + if !errors.As(err, &ref) || len(ref.Edited) != 1 || ref.Edited[0] != "render.yaml" { + t.Fatalf("err = %v, want a refusal naming render.yaml", err) + } + if string(mustRead(t, yaml)) != string(edited) || string(mustRead(t, docker)) != string(before) { + t.Fatal("a refused write changed a file") + } + + // A file abcd did not write is never overwritten either. + if err := os.WriteFile(docker, []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + _ = os.Remove(yaml) + if _, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.2", Fetch: goodRelease(), Repos: testRepos}); !errors.As(err, &ref) { + t.Fatalf("a foreign Dockerfile: err = %v, want a refusal", err) + } +} + +func TestBlueprintRefusals(t *testing.T) { + for _, c := range []struct { + name string + release string + fetch *fakeRelease + want string + }{ + {"not a tag", "latest", goodRelease(), "not a release tag"}, + {"a path in the tag", "v1.0.0/../x", goodRelease(), "not a release tag"}, + {"no release", "v9.9.9", &fakeRelease{}, "publishes no checksums.txt"}, + {"no linux binary", "v0.17.1", &fakeRelease{found: true, checksums: "1111111111111111111111111111111111111111111111111111111111111111 abcd-darwin-arm64\n"}, "names no abcd-linux-amd64"}, + {"garbage", "v0.17.1", &fakeRelease{found: true, checksums: "not a manifest\n"}, "malformed"}, + {"the network", "v0.17.1", &fakeRelease{err: errors.New("offline")}, "offline"}, + } { + root := t.TempDir() + _, err := WriteBlueprint(BlueprintRequest{Root: root, Release: c.release, Fetch: c.fetch, Repos: testRepos}) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: err = %v, want %q", c.name, err, c.want) + } + if _, statErr := os.Stat(filepath.Join(root, ".abcd")); statErr == nil { + t.Errorf("%s: a refusal created .abcd/", c.name) + } + } +} + +func TestBlueprintRefusesASymlinkedDirectory(t *testing.T) { + root := t.TempDir() + elsewhere := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(elsewhere, filepath.Join(root, ".abcd", "relay")); err != nil { + t.Fatal(err) + } + if _, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos}); err == nil { + t.Fatal("wrote through a symlinked .abcd/relay") + } + if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { + t.Fatal("a file landed outside the checkout") + } +} + +func mustRead(t *testing.T, p string) []byte { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return b +} + +// TestBlueprintRefusesWithoutARepositoryToServe: the relay it deploys +// listens beyond loopback, so it must name what it serves. +func TestBlueprintRefusesWithoutARepositoryToServe(t *testing.T) { + for _, repos := range [][]string{nil, {"acme"}, {"acme/brief\"\nx: y"}} { + root := t.TempDir() + if _, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.1", Fetch: goodRelease(), Repos: repos}); err == nil { + t.Errorf("repos %q accepted", repos) + } + if _, err := os.Stat(filepath.Join(root, ".abcd")); err == nil { + t.Errorf("repos %q: a refusal wrote .abcd/", repos) + } + } +} + +// TestBlueprintNamesThePageOriginsAndTheRelayOrigin: render.yaml names the +// page origins the relay admits, https://abcdesign.app unless others are +// given, and no sign-in app type, since the relay signs in through a GitHub +// App alone; with a relay origin, relays.json names it as the repository's +// trusted relay, stamped like the other files, and without one no +// relays.json is written. +func TestBlueprintNamesThePageOriginsAndTheRelayOrigin(t *testing.T) { + files := renderBlueprint("v0.17.1", testDigest, testRepos, nil) + yaml := string(files["render.yaml"]) + if !strings.Contains(yaml, "- key: ABCD_SESSION_ORIGINS\n value: \"https://abcdesign.app\"") { + t.Errorf("render.yaml does not name the page origin:\n%s", yaml) + } + if strings.Contains(yaml, "APP_TYPE") || strings.Contains(yaml, "oauth") { + t.Errorf("render.yaml still names a sign-in app type:\n%s", yaml) + } + files = renderBlueprint("v0.17.1", testDigest, testRepos, []string{"https://pages.example.org", "https://abcdesign.app"}) + if !strings.Contains(string(files["render.yaml"]), "value: \"https://pages.example.org,https://abcdesign.app\"") { + t.Errorf("render.yaml does not name the given origins:\n%s", files["render.yaml"]) + } + if _, err := WriteBlueprint(BlueprintRequest{Root: t.TempDir(), Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos, Origins: []string{"http://pages.example.org"}}); err == nil { + t.Error("an http page origin was written into the blueprint") + } + // A page served below a base path is named with it, as `abcd session + // page --base-path` writes it: the relay's sign-in forwards only there. + based, err := WriteBlueprint(BlueprintRequest{Root: t.TempDir(), Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos, + Origins: []string{"https://Pages.Example.org:443/abcdesign", "https://abcdesign.app/"}}) + if err != nil || strings.Join(based.Origins, ",") != "https://pages.example.org/abcdesign/,https://abcdesign.app" { + t.Errorf("a page origin with a base path: %v %v", based.Origins, err) + } + for _, bad := range []string{"https://pages.example.org/x/../y/", "https://pages.example.org/a b/", "https://pages.example.org//", "https://pages.example.org/a?x=1"} { + if _, err := WriteBlueprint(BlueprintRequest{Root: t.TempDir(), Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos, Origins: []string{bad}}); err == nil { + t.Errorf("page origin %q was written into the blueprint", bad) + } + } + + root := t.TempDir() + res, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos}) + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 2 { + t.Fatalf("without a relay origin: %+v", res.Files) + } + if res.Callback != "" { + t.Errorf("without a relay origin the blueprint names callback %q", res.Callback) + } + if _, err := os.Stat(filepath.Join(root, ".abcd", "relay", "relays.json")); err == nil { + t.Fatal("relays.json was written without a relay origin") + } + + res, err = WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos, RelayOrigin: "https://Relay.Example.org/"}) + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 3 || res.Files[2].Path != ".abcd/relay/relays.json" || res.Files[2].State != "written" { + t.Fatalf("with a relay origin: %+v", res.Files) + } + // The callback the team's app must list is the relay's own. + if res.Callback != "https://relay.example.org/auth/callback" { + t.Errorf("callback = %q, want the relay's own", res.Callback) + } + data, err := os.ReadFile(filepath.Join(root, ".abcd", "relay", "relays.json")) + if err != nil { + t.Fatal(err) + } + golden(t, "relays.json.golden", data) + var got struct { + Relays []string `json:"relays"` + } + if err := json.Unmarshal(data, &got); err != nil || len(got.Relays) != 1 || got.Relays[0] != "https://relay.example.org" { + t.Fatalf("relays.json = %v %s", err, data) + } + + // Untouched, it is replaced; edited, it is refused and nothing written. + if _, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.2", Fetch: goodRelease(), Repos: testRepos, RelayOrigin: "https://other.example.org"}); err != nil { + t.Fatalf("replacing an untouched relays.json: %v", err) + } + path := filepath.Join(root, ".abcd", "relay", "relays.json") + if err := os.WriteFile(path, []byte(`{"relays": ["https://evil.example"]}`), 0o644); err != nil { + t.Fatal(err) + } + var ref *BlueprintRefusal + if _, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.2", Fetch: goodRelease(), Repos: testRepos, RelayOrigin: "https://relay.example.org"}); !errors.As(err, &ref) { + t.Fatalf("an edited relays.json: %v, want a refusal", err) + } +} + +func TestRelayOriginsAreReadStrictly(t *testing.T) { + for in, want := range map[string]string{ + "https://relay.example.org": "https://relay.example.org", + "https://Relay.Example.org/": "https://relay.example.org", + "https://relay.example.org:8443": "https://relay.example.org:8443", + "https://abcd-session.onrender.com": "https://abcd-session.onrender.com", + // A browser's origin never carries the scheme's default port, so + // neither does the relay's: GitHub matches a redirect_uri exactly. + "https://r.onrender.com:443": "https://r.onrender.com", + "https://R.onrender.com:443/": "https://r.onrender.com", + "https://r.onrender.com:80": "https://r.onrender.com:80", + } { + if got, err := NormalizeRelayOrigin(in); err != nil || got != want { + t.Errorf("%q = %q, %v; want %q", in, got, err, want) + } + } + for _, in := range []string{ + "", "http://relay.example.org", "relay.example.org", "https://relay.example.org/path", + "https://user@relay.example.org", "https://relay.example.org?x=1", "https://*.example.org", + "https://relay.example.org#f", "https://re\"lay.example.org", "wss://relay.example.org", + } { + if _, err := NormalizeRelayOrigin(in); err == nil { + t.Errorf("%q accepted", in) + } + } +} + +// TestBlueprintRefusesAReleaseWithoutTheSessionVerb: a release older than the +// first to carry `abcd session` would build a relay container that exits at +// once, so the blueprint refuses it before reading anything; the release the +// running abcd is counts as carrying it, whatever the constant says (final +// review, docs item 1). +func TestBlueprintRefusesAReleaseWithoutTheSessionVerb(t *testing.T) { + if firstSessionRelease != "v0.17.0" { + t.Fatalf("firstSessionRelease = %s; the release cut sets it to the first release carrying abcd session", firstSessionRelease) + } + for _, c := range []struct { + release, running string + ok bool + }{ + {"v0.14.1", "", false}, + {"v0.16.9", "dev", false}, + {"v0.17.0-rc.1", "", true}, + {"v0.17.0", "", true}, + {"v1.0.0", "", true}, + {"v0.15.2", "v0.15.2", true}, + {"v0.15.1", "v0.15.2", false}, + } { + rel := goodRelease() + _, err := WriteBlueprint(BlueprintRequest{Root: t.TempDir(), Release: c.release, Running: c.running, Fetch: rel, Repos: testRepos}) + if c.ok && err != nil { + t.Errorf("%s (running %q): %v, want it pinned", c.release, c.running, err) + } + if !c.ok && (err == nil || !strings.Contains(err.Error(), "abcd session") || len(rel.asked) != 0) { + t.Errorf("%s (running %q): %v, fetched %v; want a refusal naming abcd session, before any fetch", c.release, c.running, err, rel.asked) + } + } +} + +// TestBlueprintSitsAtTheRootForTheDeployButton: Render's Deploy button takes +// only the repository (and a branch) and reads the Blueprint at the +// repository's root (render.com/docs/deploy-to-render, +// render.com/docs/infrastructure-as-code), so render.yaml is written there. A +// root render.yaml the team wrote themselves is never touched: the Blueprint +// goes under .abcd/relay/ instead, to be picked by its Blueprint Path on +// Render's New Blueprint form, and the result says the button does not apply. +// The team's GitHub App client id, public, is written as a plain value, so +// Render's form asks only for the secret. +func TestBlueprintSitsAtTheRootForTheDeployButton(t *testing.T) { + root := t.TempDir() + res, err := WriteBlueprint(BlueprintRequest{Root: root, Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos, ClientID: "Iv23liAbC"}) + if err != nil { + t.Fatal(err) + } + yaml := string(mustRead(t, filepath.Join(root, "render.yaml"))) + for _, want := range []string{ + "- key: ABCD_SESSION_GITHUB_CLIENT_ID\n value: \"Iv23liAbC\"", + "- key: ABCD_SESSION_GITHUB_CLIENT_SECRET\n sync: false", + "dockerfilePath: ./.abcd/relay/Dockerfile", + "render.com/deploy?repo=https://github.com/acme/brief", + } { + if !strings.Contains(yaml, want) { + t.Errorf("render.yaml lacks %q:\n%s", want, yaml) + } + } + if _, err := os.Stat(filepath.Join(root, ".abcd", "relay", "render.yaml")); err == nil { + t.Error("a second Blueprint was written under .abcd/relay/") + } + if res.DeployURL != "https://render.com/deploy?repo=https://github.com/acme/brief" { + t.Errorf("deploy url = %q", res.DeployURL) + } + + own := t.TempDir() + theirs := []byte("services:\n - type: web\n name: their-app\n") + if err := os.WriteFile(filepath.Join(own, "render.yaml"), theirs, 0o644); err != nil { + t.Fatal(err) + } + res, err = WriteBlueprint(BlueprintRequest{Root: own, Release: "v0.17.1", Fetch: goodRelease(), Repos: testRepos}) + if err != nil { + t.Fatal(err) + } + if string(mustRead(t, filepath.Join(own, "render.yaml"))) != string(theirs) { + t.Fatal("the team's own render.yaml was changed") + } + if res.BlueprintPath != ".abcd/relay/render.yaml" || res.DeployButton || res.DeployURL != "" { + t.Fatalf("beside the team's own render.yaml: %+v", res) + } + if y := string(mustRead(t, filepath.Join(own, ".abcd", "relay", "render.yaml"))); !strings.Contains(y, "Blueprint Path") { + t.Errorf("the Blueprint under .abcd/relay/ does not say how to pick it:\n%s", y) + } +} diff --git a/internal/core/session/bucket.go b/internal/core/session/bucket.go new file mode 100644 index 000000000..1a6887d5c --- /dev/null +++ b/internal/core/session/bucket.go @@ -0,0 +1,35 @@ +package session + +import "time" + +// bucket is a token bucket: it holds up to burst tokens, refills at rate +// tokens a second, and each message takes one (or, counted in bytes, its +// size). Its zero value is full on first use. Called under the lock that +// guards its owner, with the relay's clock. +type bucket struct { + tokens float64 + last time.Time + used bool +} + +// take spends a token if one is left, refilling first for the time since the +// last call. +func (b *bucket) take(now time.Time, rate, burst int) bool { + return b.takeN(now, rate, burst, 1) +} + +// takeN spends n tokens if that many are left, refilling first. +func (b *bucket) takeN(now time.Time, rate, burst, n int) bool { + if !b.used { + b.used, b.tokens, b.last = true, float64(burst), now + } + if elapsed := now.Sub(b.last); elapsed > 0 { + b.tokens = min(float64(burst), b.tokens+elapsed.Seconds()*float64(rate)) + b.last = now + } + if b.tokens < float64(n) { + return false + } + b.tokens -= float64(n) + return true +} diff --git a/internal/core/session/codes.go b/internal/core/session/codes.go new file mode 100644 index 000000000..5aa7420da --- /dev/null +++ b/internal/core/session/codes.go @@ -0,0 +1,174 @@ +package session + +import ( + "errors" + "io" + "net/netip" + "strings" + "time" +) + +// CodeAlphabet is the join code's 31 symbols: capital letters and digits +// without the ones read for each other (I, L, O, 0, 1), as the rascals game +// server's room and link codes use. Six of them make about 887 million codes. +const CodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +// codeLength is the join code's length. +const codeLength = 6 + +// maxCodeInput caps what a join code may be typed as before it is read: the +// six symbols with a separator or two. +const maxCodeInput = 12 + +// newCode draws a join code no live session holds, from r.rand. A byte at or +// above 248 (eight times 31) is drawn again rather than folded, so every +// symbol is equally likely. Called under the lock. +func (r *Relay) newCode() (string, error) { + const limit = byte(len(CodeAlphabet) * 8) + for attempt := 0; attempt < 16; attempt++ { + var b strings.Builder + buf := make([]byte, 1) + for b.Len() < codeLength { + if _, err := io.ReadFull(r.rand, buf); err != nil { + return "", err + } + if buf[0] >= limit { + continue + } + b.WriteByte(CodeAlphabet[int(buf[0])%len(CodeAlphabet)]) + } + if _, taken := r.rooms[b.String()]; !taken { + return b.String(), nil + } + } + return "", errors.New("no free join code") +} + +// normalizeCode reads a join code as a person types it: case ignored, spaces +// and dashes dropped. Anything longer than a code with separators, or with a +// symbol outside the alphabet once read, is no code (""). +func normalizeCode(s string) string { + if len(s) > maxCodeInput { + return "" + } + var b strings.Builder + for _, r := range strings.ToUpper(s) { + switch { + case r == ' ' || r == '-': + continue + case strings.ContainsRune(CodeAlphabet, r): + b.WriteRune(r) + default: + return "" + } + } + if b.Len() != codeLength { + return "" + } + return b.String() +} + +// The address ranges Tailscale gives a tailnet's devices: IPv4 from the CGNAT +// range 100.64.0.0/10 (https://tailscale.com/kb/1015/100.x-addresses) and IPv6 +// from the unique local prefix fd7a:115c:a1e0::/48 +// (https://tailscale.com/kb/1033/ip-and-dns-addresses). Each device holds one +// stable address in each, given by the tailnet, not chosen by the visitor. +var ( + tailnet4 = netip.MustParsePrefix("100.64.0.0/10") + tailnet6 = netip.MustParsePrefix("fd7a:115c:a1e0::/48") +) + +// IsTailnetAddr reports whether a is an address Tailscale gives a device. +func IsTailnetAddr(a netip.Addr) bool { + a = a.Unmap().WithZone("") + return tailnet4.Contains(a) || tailnet6.Contains(a) +} + +// AddressKey is the key the relay counts a client address by, for a front +// door that bounds something per address as the relay's own limits do. +func AddressKey(addr string) string { return addressKey(addr) } + +// addressKey is the key the failed-join limit and the connection caps count +// by: an IPv4 address, or an IPv6 /48 network. One home or phone usually holds +// a whole /64, and a site is commonly given a /48 of 65,536 of them, so +// counting single IPv6 addresses, or /64s, would let one visitor rotate +// through millions. A tailnet IPv6 address is the exception and its own key: +// every device of a tailnet shares one /48 of Tailscale's range, and a device +// cannot pick another address in it. An address that does not parse is its +// own key. +func addressKey(addr string) string { + a, err := netip.ParseAddr(strings.TrimSpace(addr)) + if err != nil { + return strings.TrimSpace(addr) + } + a = a.Unmap().WithZone("") + if a.Is4() || tailnet6.Contains(a) { + return a.String() + } + p, err := a.Prefix(48) + if err != nil { + return a.String() + } + return p.String() +} + +// addrWindow counts events per address within a window (wrong codes, and +// sessions opened), remembering at most max addresses (the oldest are +// forgotten first). +type addrWindow struct { + limit int + window time.Duration + max int + byAddr map[string][]time.Time + order []string // insertion order, oldest first +} + +func newAddrWindow(limit int, window time.Duration, max int) *addrWindow { + return &addrWindow{limit: limit, window: window, max: max, byAddr: map[string][]time.Time{}} +} + +// recent is addr's events inside the window. +func (f *addrWindow) recent(addr string, now time.Time) []time.Time { + var out []time.Time + for _, t := range f.byAddr[addr] { + if now.Sub(t) < f.window { + out = append(out, t) + } + } + return out +} + +// blocked reports whether addr has used up its events. +func (f *addrWindow) blocked(addr string, now time.Time) bool { + return len(f.recent(addr, now)) >= f.limit +} + +// note counts one event from addr and returns its count in the window. +func (f *addrWindow) note(addr string, now time.Time) int { + list := append(f.recent(addr, now), now) + if _, seen := f.byAddr[addr]; !seen { + f.order = append(f.order, addr) + } + f.byAddr[addr] = list + if len(f.byAddr) > f.max { + f.sweep(now) + for len(f.byAddr) > f.max && len(f.order) > 0 { + delete(f.byAddr, f.order[0]) + f.order = f.order[1:] + } + } + return len(list) +} + +// sweep forgets every address with no event inside the window. +func (f *addrWindow) sweep(now time.Time) { + kept := f.order[:0] + for _, a := range f.order { + if len(f.recent(a, now)) == 0 { + delete(f.byAddr, a) + continue + } + kept = append(kept, a) + } + f.order = kept +} diff --git a/internal/core/session/doccomment_test.go b/internal/core/session/doccomment_test.go new file mode 100644 index 000000000..276b6e3a5 --- /dev/null +++ b/internal/core/session/doccomment_test.go @@ -0,0 +1,40 @@ +package session + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" +) + +// TestAddressKeyCommentsAreTheirOwn: AddressKey and addressKey each carry +// their own doc comment, which begins with the function's name, so neither +// reads as the other's. +func TestAddressKeyCommentsAreTheirOwn(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "codes.go", nil, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || (fn.Name.Name != "AddressKey" && fn.Name.Name != "addressKey") { + continue + } + seen[fn.Name.Name] = true + doc := "" + if fn.Doc != nil { + doc = fn.Doc.Text() + } + if !strings.HasPrefix(doc, fn.Name.Name+" ") { + t.Errorf("%s's doc comment does not begin with its name:\n%s", fn.Name.Name, doc) + } + if fn.Name.Name == "AddressKey" && strings.Contains(doc, "addressKey is") { + t.Errorf("AddressKey's doc comment carries addressKey's:\n%s", doc) + } + } + if !seen["AddressKey"] || !seen["addressKey"] { + t.Fatalf("codes.go declares %v, want AddressKey and addressKey", seen) + } +} diff --git a/internal/core/session/relay.go b/internal/core/session/relay.go new file mode 100644 index 000000000..1ac0915ee --- /dev/null +++ b/internal/core/session/relay.go @@ -0,0 +1,1640 @@ +// Package session is the brief session's relay, without a transport +// (itd-2610091016262335, spc-2610141709430146 step 1): the rooms a host opens, +// the join codes that reach them, who is a guest and who a team member, the +// log of opaque edit updates each room replays to a newcomer, a guest's +// suggestions routed to the members, the roster, and the limits that bound all +// of it. +// +// It never writes to stdout, opens no file and knows no transport: the front +// door (internal/surface/session) hands it one Frame at a time through a Conn +// and gives it a Peer to answer on. It holds everything in memory, and a room +// is dropped whole when its host closes it or when nobody has been connected +// for the idle limit (adr-2610032150581128, as amended 2026-10-14: no project +// content at rest). +// +// Each participant is given a rejoin token when they host or join: a dropped +// connection comes back as the same participant by presenting it, without the +// join code, so a code the host has since rotated does not shut them out. The +// relay keeps only the token's SHA-256 digest, and the token dies with its +// session. +// +// The edit updates and awareness messages are Yjs's, carried as opaque bytes +// behind a one-byte tag; the relay never parses them, nor the brief they +// carry. A GitHub token presented to verify is handed to the Verifier and not +// kept: only the login and the answer are. +package session + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "sync" + "time" + "unicode" +) + +// The binary frame tags: the first byte of every binary frame. +const ( + // TagUpdate is a Yjs document update: logged and forwarded, and accepted + // from team members only. + TagUpdate byte = 0 + // TagAwareness is a Yjs awareness message (cursors and selections): + // forwarded from anyone, as a TagAttributedAwareness, and never logged. + TagAwareness byte = 1 + // TagAttributedUpdate is how the relay sends a TagUpdate on, live and in + // the replay, and it is never accepted from a participant: the tag, the + // sender's participant number as four big-endian bytes (the N of its + // roster id pN), then the update's bytes as the member sent them. The + // roster's authors map names each number's login, so a page can check + // that the client ids inside an update are credited to whoever sent it. + TagAttributedUpdate byte = 2 + // TagAttributedAwareness is how the relay sends a TagAwareness on, and it + // is never accepted from a participant: the tag, the sender's participant + // number as four big-endian bytes, then the message's bytes as the + // participant sent them, exactly as TagAttributedUpdate carries an update. + // A page keeps an awareness state only when the roster ties its client id + // to that sender, so nobody's cursor can carry someone else's name. + TagAttributedAwareness byte = 3 +) + +// attributedHeader is the length of an attributed frame's tag and number, +// TagAttributedUpdate's and TagAttributedAwareness's alike. +const attributedHeader = 5 + +// attribute is data, a participant's tagged frame, as the relay sends it on: +// tag, then c's participant number as four big-endian bytes, then the bytes +// after the participant's own tag, copied as they came and never read. +func (c *Conn) attribute(tag byte, data []byte) []byte { + out := make([]byte, attributedHeader+len(data)-1) + out[0] = tag + binary.BigEndian.PutUint32(out[1:attributedHeader], c.num) + copy(out[attributedHeader:], data[1:]) + return out +} + +// Frame is one WebSocket message: a JSON control message in a text frame, or +// a tagged Yjs message in a binary frame. +type Frame struct { + Binary bool + Data []byte +} + +// Peer is the transport's handle on one connection. +type Peer interface { + // Send queues f without blocking. False means the connection cannot take + // it (its queue is full), and the relay drops the connection. + Send(f Frame) bool + // SendLog queues a session's log replay without blocking, as one item + // however long the log is: the transport writes each entry as its own + // binary frame, in order, waiting on the connection for each rather than + // queueing them, before anything queued after it. The entries are never + // changed once logged, so the transport may hold them while it writes. + // False means the connection cannot take it, as for Send. + SendLog(entries [][]byte) bool + // Entered tells the transport the connection is in a session now, so it + // may let more frames wait for it than for one that is not. + Entered() + // Close ends the connection; policy marks a refusal (WebSocket 1008) + // rather than a normal end. It must not call back into the relay. + Close(policy bool, reason string) +} + +// Verifier asks GitHub who a token belongs to and whether that login may push +// to repo. The token is not kept by the relay after the call returns. +type Verifier interface { + Verify(ctx context.Context, token, repo string) (login string, push bool, err error) +} + +// Limits bound everything the relay holds. +type Limits struct { + // MaxMessageBytes caps one frame, text or binary. + MaxMessageBytes int + // MaxLogBytes caps one session's update log. Each entry is charged its + // bytes and LogEntryOverhead, what holding it costs beyond them. + MaxLogBytes int + // MaxLogEntries caps the number of entries in one session's log. + MaxLogEntries int + // MaxParticipants caps the connections in one session. + MaxParticipants int + // MaxSessions caps the sessions one relay holds at once. + MaxSessions int + // IdleAfter is how long a session with nobody connected is kept. + IdleAfter time.Duration + // MaxFailedJoins wrong codes from one address within FailedJoinWindow + // shut that address out of joining until the window passes. + MaxFailedJoins int + FailedJoinWindow time.Duration + // MaxTrackedAddrs caps the addresses the failed-join count remembers. + MaxTrackedAddrs int + // RelayFailedJoins wrong codes from every address together within + // FailedJoinWindow pause joining by code for everybody for a window, so + // spreading guesses over many addresses does not help (the rascals + // server's global limit). A rejoin token and a verified team member are + // not held up, and wrong rejoin tokens do not count towards it. + RelayFailedJoins int + // MaxVerifies caps the verify attempts of one connection, so the relay + // cannot be used to hammer GitHub. + MaxVerifies int + // MaxCredits caps the Yjs client ids one session credits to members, and + // the participants it names as authors of its log. + MaxCredits int + // AwarenessPerSecond and AwarenessBurst are one connection's token + // bucket for awareness frames (cursors and selections). + AwarenessPerSecond int + AwarenessBurst int + // SuggestPerSecond and SuggestBurst are one connection's token bucket + // for a guest's suggestions. + SuggestPerSecond int + SuggestBurst int + // UpdatesPerSecond and UpdateBurst are one connection's token bucket for + // a member's edit updates. + UpdatesPerSecond int + UpdateBurst int + // MaxHostsPerAddr sessions may be opened from one address within + // HostWindow. + MaxHostsPerAddr int + HostWindow time.Duration + // MaxRejoinTokens caps the rejoin tokens one session keeps; past it, the + // token of the participant who left longest ago is forgotten first. It is + // never below MaxParticipants, so a connected participant's token is never + // the one forgotten. + MaxRejoinTokens int + // MaxConnections caps the connections the relay holds at once, in a + // session or not, and MaxConnsPerAddr those from one address, keyed as + // the failed-join limit keys it (addressKey). + MaxConnections int + MaxConnsPerAddr int + // AdmitWithin is how long a connection may stay open without hosting, + // joining or rejoining; the transport calls Conn.Expire when it ends. + AdmitWithin time.Duration + // MaxAwarenessBytes caps one awareness frame: a cursor, a selection and + // a name are a few hundred bytes. + MaxAwarenessBytes int + // AwarenessBytesPerSecond and AwarenessBytesBurst are one session's + // token bucket for awareness, in bytes, shared by every participant: the + // frames each connection may send are bounded by its own buckets, and + // what the session forwards to each of its connections by this one. + AwarenessBytesPerSecond int + AwarenessBytesBurst int + // AwarenessConnBytesPerSecond and AwarenessConnBytesBurst are one + // connection's token bucket for awareness, in bytes. Its rate is below + // the session's, so one participant cannot spend the session's alone and + // hold up everyone else's cursors. + AwarenessConnBytesPerSecond int + AwarenessConnBytesBurst int + // MaxSuggestBytes caps one suggestion's body; MaxSuggestsPerSeat and + // MaxSuggestBytesPerSeat the suggestions, and their bytes, one participant + // makes, counted on their seat so a rejoin does not start them afresh; + // and MaxRoomSuggestBytes the bytes of every suggestion one session + // forwards, a backstop for guests who join anew by code: a member's page + // records each suggestion in the session's document, so they land in its + // log. + MaxSuggestBytes int + MaxSuggestsPerSeat int + MaxSuggestBytesPerSeat int + MaxRoomSuggestBytes int + // MessagesPerSecond and MessageBurst are one connection's token bucket + // for every control message and every binary frame the relay refuses; a + // message past it is dropped unanswered, without taking the relay's + // lock. A connection refused MaxRefusals times is closed. An accepted + // update or awareness frame spends nothing of it: those have their own + // buckets. + MessagesPerSecond int + MessageBurst int + MaxRefusals int + // MaxSessionLife is how long a session lasts from when it was opened, + // however busy: it then ends as a host's close does, with closed + // {reason: "expired"}. A rejoin token restores the role its participant + // had when it was issued, so this bounds how long a member whose access + // GitHub has since revoked keeps it. + MaxSessionLife time.Duration + // MaxExchangesPerAddr sign-in exchanges (/auth/token) are allowed from + // one address, keyed as wrong codes are (addressKey), within + // ExchangeWindow. + MaxExchangesPerAddr int + ExchangeWindow time.Duration + // MaxQueuedBytes caps the bytes waiting on one connection's send queue, + // besides its count of items; the transport drops a connection that + // falls further behind. A log replay is not charged: its entries are the + // session's own, held anyway. + MaxQueuedBytes int +} + +// LogEntryOverhead is what each log entry is charged against MaxLogBytes +// beyond its own bytes: the slice that holds it, its allocation's rounding +// and the attribution the relay adds to it. Without it a log of one-byte +// updates would hold many times its limit in memory. +const LogEntryOverhead = 64 + +// defaultLimits are the relay's limits unless a caller sets others. Sixteen +// sessions of at most 8 MiB of log each keep a relay inside a free host's +// memory. +func defaultLimits() Limits { + return Limits{ + MaxMessageBytes: 256 << 10, + MaxLogBytes: 8 << 20, + MaxLogEntries: 50000, + MaxParticipants: 32, + MaxSessions: 16, + IdleAfter: 30 * time.Minute, + MaxFailedJoins: 20, + FailedJoinWindow: 10 * time.Minute, + MaxTrackedAddrs: 10000, + // About thirty addresses' worth of wrong codes (MaxFailedJoins each) + // pause joining by code for everybody: an accepted tradeoff, recorded + // in spc-2610141709430146. + RelayFailedJoins: 600, + MaxVerifies: 5, + MaxCredits: 512, + // A cursor moving is a burst of awareness frames, settling at a few a + // second; a guest suggests by hand. + AwarenessPerSecond: 20, + AwarenessBurst: 40, + SuggestPerSecond: 1, + SuggestBurst: 5, + // Typing is a few updates a second; a paste or an undo is a burst. + UpdatesPerSecond: 60, + UpdateBurst: 200, + MaxHostsPerAddr: 3, + HostWindow: 10 * time.Minute, + MaxRejoinTokens: 128, + MaxConnections: 256, + MaxConnsPerAddr: 16, + AdmitWithin: 30 * time.Second, + // A cursor's awareness frame is a few hundred bytes; 32 participants + // moving at once stay well inside the session's byte rate. + MaxAwarenessBytes: 8 << 10, + AwarenessBytesPerSecond: 128 << 10, + AwarenessBytesBurst: 512 << 10, + // One participant's share: a quarter of the session's rate. + AwarenessConnBytesPerSecond: 32 << 10, + AwarenessConnBytesBurst: 64 << 10, + // A suggestion carries a passage and its replacement (the page caps + // a replacement at 64,000 characters); one participant may make a + // quarter of the session's suggestion bytes. + MaxSuggestBytes: 70 << 10, + MaxSuggestsPerSeat: 100, + MaxSuggestBytesPerSeat: 256 << 10, + MaxRoomSuggestBytes: 1 << 20, + // The page sends a control message now and then: hosting, joining, + // signing in, registering, suggesting. + MessagesPerSecond: 50, + MessageBurst: 100, + MaxRefusals: 1000, + MaxSessionLife: 12 * time.Hour, + MaxExchangesPerAddr: 10, + ExchangeWindow: 10 * time.Minute, + MaxQueuedBytes: 8 << 20, + } +} + +// Options configure a Relay. A zero Now is time.Now and a nil Rand is +// crypto/rand; a zero limit takes its default. +type Options struct { + Limits Limits + Verifier Verifier + Now func() time.Time + Rand io.Reader + // Repos and Owners are the repositories the relay serves sessions for: + // each owner/name in Repos, and every repository of each owner in + // Owners, compared without case. Nobody verifies against, or hosts, any + // other. With both empty it serves every repository, which the front + // door permits only on a relay bound to loopback alone. + Repos []string + Owners []string +} + +// Stats is what the relay holds, in counts: after a session is purged, none of +// it counts towards any of these. Connections counts every socket the +// transport has not yet ended, a connection the relay closed included, until +// its final Leave. +type Stats struct { + Connections int + Sessions int + Codes int + Participants int + LogBytes int + Credits int + RejoinTokens int +} + +// The two roles. +const ( + roleGuest = "guest" + roleMember = "member" +) + +// Relay holds the sessions. One mutex guards all of it; Peer.Send never +// blocks, so nothing waits on a connection while holding it. +type Relay struct { + mu sync.Mutex + limits Limits + verifier Verifier + now func() time.Time + rand io.Reader + rooms map[string]*room // by join code + seats map[string]*seat // by the digest of their rejoin token + failed *addrWindow + hosted *addrWindow + // exchanges counts sign-in exchanges per address. + exchanges *addrWindow + // allFailed counts wrong codes from every address together. + allFailed struct { + count int + since time.Time + pausedUntil time.Time + } + nextConn uint32 + open int // connections held, in a session or not + openBy map[string]int // connections held, by address key + repos map[string]bool + owners map[string]bool +} + +// New builds a relay. +func New(o Options) *Relay { + l := o.Limits + d := defaultLimits() + fill := func(v *int, def int) { + if *v <= 0 { + *v = def + } + } + fill(&l.MaxMessageBytes, d.MaxMessageBytes) + fill(&l.MaxLogBytes, d.MaxLogBytes) + fill(&l.MaxLogEntries, d.MaxLogEntries) + fill(&l.UpdatesPerSecond, d.UpdatesPerSecond) + fill(&l.UpdateBurst, d.UpdateBurst) + fill(&l.MaxParticipants, d.MaxParticipants) + fill(&l.MaxSessions, d.MaxSessions) + fill(&l.MaxFailedJoins, d.MaxFailedJoins) + fill(&l.MaxTrackedAddrs, d.MaxTrackedAddrs) + fill(&l.RelayFailedJoins, d.RelayFailedJoins) + fill(&l.MaxVerifies, d.MaxVerifies) + fill(&l.MaxCredits, d.MaxCredits) + fill(&l.AwarenessPerSecond, d.AwarenessPerSecond) + fill(&l.AwarenessBurst, d.AwarenessBurst) + fill(&l.SuggestPerSecond, d.SuggestPerSecond) + fill(&l.SuggestBurst, d.SuggestBurst) + fill(&l.MaxHostsPerAddr, d.MaxHostsPerAddr) + fill(&l.MaxRejoinTokens, d.MaxRejoinTokens) + fill(&l.MaxConnections, d.MaxConnections) + fill(&l.MaxConnsPerAddr, d.MaxConnsPerAddr) + fill(&l.MaxAwarenessBytes, d.MaxAwarenessBytes) + fill(&l.AwarenessBytesPerSecond, d.AwarenessBytesPerSecond) + fill(&l.AwarenessBytesBurst, d.AwarenessBytesBurst) + fill(&l.MaxSuggestBytes, d.MaxSuggestBytes) + fill(&l.MaxSuggestsPerSeat, d.MaxSuggestsPerSeat) + fill(&l.MaxSuggestBytesPerSeat, d.MaxSuggestBytesPerSeat) + fill(&l.AwarenessConnBytesPerSecond, d.AwarenessConnBytesPerSecond) + fill(&l.AwarenessConnBytesBurst, d.AwarenessConnBytesBurst) + fill(&l.MaxRoomSuggestBytes, d.MaxRoomSuggestBytes) + fill(&l.MessagesPerSecond, d.MessagesPerSecond) + fill(&l.MessageBurst, d.MessageBurst) + fill(&l.MaxRefusals, d.MaxRefusals) + fill(&l.MaxExchangesPerAddr, d.MaxExchangesPerAddr) + fill(&l.MaxQueuedBytes, d.MaxQueuedBytes) + // A frame the session's byte burst cannot hold could never be sent. + if l.AwarenessBytesBurst < l.MaxAwarenessBytes { + l.AwarenessBytesBurst = l.MaxAwarenessBytes + } + if l.AwarenessConnBytesBurst < l.MaxAwarenessBytes { + l.AwarenessConnBytesBurst = l.MaxAwarenessBytes + } + if l.MaxRejoinTokens < l.MaxParticipants { + l.MaxRejoinTokens = l.MaxParticipants + } + if l.IdleAfter <= 0 { + l.IdleAfter = d.IdleAfter + } + if l.FailedJoinWindow <= 0 { + l.FailedJoinWindow = d.FailedJoinWindow + } + if l.HostWindow <= 0 { + l.HostWindow = d.HostWindow + } + if l.AdmitWithin <= 0 { + l.AdmitWithin = d.AdmitWithin + } + if l.MaxSessionLife <= 0 { + l.MaxSessionLife = d.MaxSessionLife + } + if l.ExchangeWindow <= 0 { + l.ExchangeWindow = d.ExchangeWindow + } + r := &Relay{limits: l, verifier: o.Verifier, now: o.Now, rand: o.Rand, rooms: map[string]*room{}, seats: map[string]*seat{}, openBy: map[string]int{}} + if r.now == nil { + r.now = time.Now + } + if r.rand == nil { + r.rand = rand.Reader + } + r.repos, r.owners = map[string]bool{}, map[string]bool{} + for _, repo := range o.Repos { + r.repos[strings.ToLower(repo)] = true + } + for _, owner := range o.Owners { + r.owners[strings.ToLower(owner)] = true + } + r.failed = newAddrWindow(l.MaxFailedJoins, l.FailedJoinWindow, l.MaxTrackedAddrs) + r.hosted = newAddrWindow(l.MaxHostsPerAddr, l.HostWindow, l.MaxTrackedAddrs) + r.exchanges = newAddrWindow(l.MaxExchangesPerAddr, l.ExchangeWindow, l.MaxTrackedAddrs) + return r +} + +// AllowExchange counts one sign-in exchange from addr (the client's address +// as the transport judged it, keyed by addressKey) and reports whether it may +// go ahead: at most MaxExchangesPerAddr in ExchangeWindow. A refused one is +// not counted. +func (r *Relay) AllowExchange(addr string) bool { + r.mu.Lock() + defer r.mu.Unlock() + key, now := addressKey(addr), r.now() + if r.exchanges.blocked(key, now) { + return false + } + r.exchanges.note(key, now) + return true +} + +// Limits reports the limits in force, defaults filled in. +func (r *Relay) Limits() Limits { return r.limits } + +// room is one session. +type room struct { + repo string // as the host named it + repoKey string // lower-cased, for comparison + code string + hostLogin string + conns []*Conn // in join order + log [][]byte + logBytes int // what the log is charged: each entry's bytes and overhead + credits map[uint32]string // Yjs client id -> member login + authors map[uint32]string // participant number -> login, for every member whose update is logged + emptySince time.Time + opened time.Time // when the host opened it; it ends MaxSessionLife later + suggests int + // suggestBytes is what every suggestion forwarded so far weighed. + suggestBytes int + // awareness is the session's byte bucket for every participant's + // awareness frames together. + awareness bucket + seats []*seat // every rejoin token issued, oldest first +} + +// seat is one participant as a rejoin token restores them: who they were when +// their connection last held it. The token itself is not kept, only its +// digest. +type seat struct { + key string + room *room + conn *Conn // the connection holding it now, if any + id string + num uint32 + role string + name string + login string + memberOf string + // suggested and suggestBytes count the suggestions this participant had + // forwarded, and their bytes, over every connection that held the seat. + suggested int + suggestBytes int +} + +// save records c's participant on its seat. +func (s *seat) save(c *Conn) { + s.id, s.num, s.role, s.name, s.login, s.memberOf = c.id, c.num, c.role, c.name, c.login, c.memberOf +} + +// rejoinBytes is a rejoin token's length before encoding: 128 random bits. +const rejoinBytes = 16 + +// maxRejoinInput caps what a presented rejoin token may be before it is +// hashed: a token is 22 characters. +const maxRejoinInput = 64 + +// rejoinKey is the digest a token is filed under. +func rejoinKey(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// Conn is one connection's state. Its methods are called by one goroutine per +// connection (the transport's read loop). +type Conn struct { + r *Relay + id string // "p" and num, as the roster shows it + num uint32 // the participant number attributed updates carry + peer Peer + addr string // the failed-join key: an IPv4 address or an IPv6 /64 + room *room + role string + name string + login string // the verified GitHub login, if any + memberOf string // the repository key the verified login may push to + verifies int + // left says the relay serves the connection no more: it left its + // session, or the relay closed it. released says its transport has ended + // it, and its place in the connection caps is given back; only the + // transport's final Leave does that, since a socket the relay closed may + // still be writing a log replay. + left bool + released bool + entered bool // it has hosted, joined or rejoined + seat *seat // the participant a rejoin token restores + // awareness, awarenessBytes and updates are the connection's token + // buckets for its binary frames, guarded by gate, so a frame over its + // rate is dropped without the relay's lock; suggests is its bucket for + // suggestions, guarded by the relay's lock. + awareness bucket + awarenessBytes bucket + suggests bucket + updates bucket + // answered is when a rate limit of each kind was last answered, guarded + // by gate: past its rate a frame is answered once a second at most. + answered map[string]time.Time + // gate guards the message budget. It is taken alone, or inside r.mu, + // never the other way round, so a message past the budget is dropped + // without waiting for the relay's lock. + gate sync.Mutex + // msgs is the message budget: every control message and every frame the + // relay refuses spends a token of it. + msgs bucket + // refused counts the connection's messages refused or dropped; at + // MaxRefusals the connection is closed. + refused int + // charged says the message being handled has spent its token already. + charged bool +} + +// ErrTooManyConnections is Connect's refusal: the relay, or the address, +// holds as many connections as it may. +var ErrTooManyConnections = errors.New("the relay holds as many connections as it may") + +// Connect admits a new connection from addr (the client's address as the +// transport judged it), or refuses it, touching nothing else, when the relay +// or that address holds as many connections as it may. The connection holds +// its place until it leaves. +func (r *Relay) Connect(p Peer, addr string) (*Conn, error) { + r.mu.Lock() + defer r.mu.Unlock() + key := addressKey(addr) + if r.open >= r.limits.MaxConnections || r.openBy[key] >= r.limits.MaxConnsPerAddr { + return nil, ErrTooManyConnections + } + r.open++ + r.openBy[key]++ + r.nextConn++ + return &Conn{r: r, id: "p" + strconv.FormatUint(uint64(r.nextConn), 10), num: r.nextConn, peer: p, addr: key}, nil +} + +// release gives c's place in the connection caps back, once. Only the +// transport's final Leave calls it. +func (c *Conn) release() { + if c.released { + return + } + c.released = true + r := c.r + r.open-- + if r.openBy[c.addr]--; r.openBy[c.addr] <= 0 { + delete(r.openBy, c.addr) + } +} + +// enter marks c as in a session and tells its transport so. +func (c *Conn) enter() { + if !c.entered { + c.entered = true + c.peer.Entered() + } +} + +// Expire closes c if it has not hosted, joined or rejoined. The transport +// calls it once Limits.AdmitWithin after Connect, so a connection that never +// enters a session does not hold its place. The close is a normal one, not a +// refusal: a page that waited may open another. +func (c *Conn) Expire() { + c.r.mu.Lock() + defer c.r.mu.Unlock() + if c.left || c.entered { + return + } + c.notify("join-timeout", "host, join or rejoin a session soon after connecting") + c.leave() + c.peer.Close(false, "no session joined in time") +} + +// inbound is every field a control message may carry. +type inbound struct { + Type string `json:"type"` + Repo string `json:"repo"` + Code string `json:"code"` + Name string `json:"name"` + Token string `json:"token"` + Client json.Number `json:"client"` + Body json.RawMessage `json:"body"` +} + +// Receive handles one frame from the connection. +func (c *Conn) Receive(ctx context.Context, f Frame) { + defer c.closeIfAbusive() + r := c.r + // Every control message, and every frame refused on sight, spends the + // message budget before anything else is done with it; past the budget + // it is dropped here, unanswered, without the relay's lock. An update or + // awareness frame spends it only if the relay refuses it. + onSight := !f.Binary || len(f.Data) == 0 || len(f.Data) > r.limits.MaxMessageBytes || + (f.Data[0] != TagUpdate && f.Data[0] != TagAwareness) + if !c.budget(onSight) { + // A guest's page holds its one suggestion until an answer comes, so + // a suggest dropped here is answered too, once a second at most. + if !f.Binary && isSuggest(f.Data) && c.mayAnswer("suggest") { + r.mu.Lock() + c.rateLimited("suggest") + r.mu.Unlock() + } + return + } + if len(f.Data) > r.limits.MaxMessageBytes { + r.mu.Lock() + c.fail("too-big", fmt.Sprintf("a message is at most %d bytes", r.limits.MaxMessageBytes)) + r.mu.Unlock() + return + } + if f.Binary { + // An update or awareness frame over its connection's rate is + // dropped here, without the relay's lock, and answered once a second + // at most. + if kind := c.overRate(f.Data); kind != "" { + if c.mayAnswer(kind) { + r.mu.Lock() + c.rateLimited(kind) + r.mu.Unlock() + } + return + } + r.mu.Lock() + defer r.mu.Unlock() + c.binary(f.Data) + return + } + var m inbound + dec := json.NewDecoder(strings.NewReader(string(f.Data))) + dec.UseNumber() + if err := dec.Decode(&m); err != nil || dec.More() { + r.mu.Lock() + c.fail("bad-message", "a control message is one JSON object") + r.mu.Unlock() + return + } + if m.Type == "verify" { + c.verify(ctx, m.Token, m.Repo) + return + } + r.mu.Lock() + defer r.mu.Unlock() + if c.left { + return + } + switch m.Type { + case "host": + c.host(m.Repo) + case "join": + c.join(m.Code, m.Name) + case "rejoin": + c.rejoin(m.Token) + case "suggest": + c.suggest(m.Body) + case "register": + c.register(m.Client) + case "rotate": + c.rotate() + case "close": + c.closeRoom() + default: + c.fail("unknown-message", "the relay does not know this message") + } +} + +// budget starts handling one message: a message charged on sight spends a +// token of the message budget now, and reports false, counted as refused, +// when none is left. Any other message is charged only if it is refused +// (chargeRefusal). +func (c *Conn) budget(onSight bool) bool { + c.gate.Lock() + defer c.gate.Unlock() + c.charged = onSight + if !onSight { + return true + } + if c.msgs.take(c.r.now(), c.r.limits.MessagesPerSecond, c.r.limits.MessageBurst) { + return true + } + c.refused++ + return false +} + +// chargeRefusal counts one refusal of c's message and reports whether to +// answer it: a message that has spent its token already is answered, and one +// that has not spends one now and is answered only if a token was left. +func (c *Conn) chargeRefusal() bool { + c.gate.Lock() + defer c.gate.Unlock() + c.refused++ + if c.charged { + return true + } + c.charged = true + return c.msgs.take(c.r.now(), c.r.limits.MessagesPerSecond, c.r.limits.MessageBurst) +} + +// rateAnswerEvery is how often, at most, a rate limit of one kind is +// answered on one connection; the drops between are silent. +const rateAnswerEvery = time.Second + +// overRate spends the connection's own buckets for an update or awareness +// frame (one the budget did not charge on sight) and names its kind when +// they are spent: "update" or "awareness". An awareness frame over its size +// is left for the relay to refuse. +func (c *Conn) overRate(data []byte) string { + l := &c.r.limits + c.gate.Lock() + defer c.gate.Unlock() + now := c.r.now() + switch data[0] { + case TagUpdate: + if !c.updates.take(now, l.UpdatesPerSecond, l.UpdateBurst) { + return "update" + } + case TagAwareness: + if len(data) > l.MaxAwarenessBytes { + return "" + } + if !c.awareness.take(now, l.AwarenessPerSecond, l.AwarenessBurst) || + !c.awarenessBytes.takeN(now, l.AwarenessConnBytesPerSecond, l.AwarenessConnBytesBurst, len(data)) { + return "awareness" + } + } + return "" +} + +// mayAnswer reports whether a rate limit of kind may be answered now, and +// notes it if so: once every rateAnswerEvery at most. It takes gate alone or +// inside the relay's lock. +func (c *Conn) mayAnswer(kind string) bool { + c.gate.Lock() + defer c.gate.Unlock() + now := c.r.now() + if last, ok := c.answered[kind]; ok && now.Sub(last) < rateAnswerEvery { + return false + } + if c.answered == nil { + c.answered = map[string]time.Time{} + } + c.answered[kind] = now + return true +} + +// isSuggest reports whether a control message is a suggest, read without the +// relay's lock. +func isSuggest(data []byte) bool { + if !bytes.Contains(data, []byte(`"suggest"`)) { + return false + } + var m struct { + Type string `json:"type"` + } + return json.Unmarshal(data, &m) == nil && m.Type == "suggest" +} + +// closeIfAbusive closes c once it has been refused MaxRefusals times: a page +// that keeps sending what the relay refuses is not one the relay serves. +func (c *Conn) closeIfAbusive() { + c.gate.Lock() + over := c.refused >= c.r.limits.MaxRefusals + c.gate.Unlock() + if !over { + return + } + c.r.mu.Lock() + defer c.r.mu.Unlock() + if !c.left { + c.leave() + c.peer.Close(true, "too many refused messages") + } +} + +// Leave removes the connection and gives its place in the connection caps +// back; the transport calls it when the connection has ended, and it may be +// called more than once. +func (c *Conn) Leave() { + c.r.mu.Lock() + defer c.r.mu.Unlock() + c.leave() + c.release() +} + +// leave takes c out of its session, under the lock: the relay serves it no +// more. It stays counted until its transport's Leave. +func (c *Conn) leave() { + if c.left { + return + } + c.left = true + rm := c.room + c.room = nil + if s := c.seat; s != nil && s.conn == c { + s.save(c) + s.conn = nil + } + if rm == nil { + return + } + for i, o := range rm.conns { + if o == c { + rm.conns = append(rm.conns[:i], rm.conns[i+1:]...) + break + } + } + if len(rm.conns) == 0 { + rm.emptySince = c.r.now() + return + } + c.r.broadcastRoster(rm) +} + +// send queues one message on c; a peer that cannot take it is dropped. +func (c *Conn) send(f Frame) { + if c.left { + return + } + if !c.peer.Send(f) { + c.leave() + c.peer.Close(true, "too slow") + } +} + +func (c *Conn) sendJSON(v any) { + data, err := json.Marshal(v) + if err != nil { + return + } + c.send(Frame{Data: data}) +} + +// fail refuses c's message with an error message, counted against the +// connection's message budget: past the budget the refusal is not answered. +// Its text is fixed per code and never echoes what the client sent. +func (c *Conn) fail(code, text string) { + if c.chargeRefusal() { + c.notify(code, text) + } +} + +// failSuggest refuses c's suggest as fail does, naming its kind. +func (c *Conn) failSuggest(code, text string) { + if c.chargeRefusal() { + c.sendJSON(map[string]string{"type": "error", "code": code, "kind": "suggest", "message": text}) + } +} + +// notify sends c an error message that answers nothing it sent, or a rate +// limit's: it is not counted against the message budget. +func (c *Conn) notify(code, text string) { + c.sendJSON(map[string]string{"type": "error", "code": code, "message": text}) +} + +// rateLimited answers a message dropped for coming too fast, naming its kind +// ("update", "awareness", "host" or "suggest") so the page can tell which of +// its messages went: a guest's dropped cursor must not read as a dropped +// suggestion. The connection stays open: only the message is dropped. A rate +// limit counts as no refusal: a cursor dragged quickly meets it often, and +// that is no abuse. An update or awareness frame's is sent once a second at +// most (mayAnswer). +func (c *Conn) rateLimited(kind string) { + c.sendJSON(map[string]string{"type": "error", "code": "rate-limited", "kind": kind, "message": "too many messages of this kind; this one was dropped"}) +} + +// binary handles a tagged Yjs frame, under the lock. +func (c *Conn) binary(data []byte) { + if c.left { + return + } + rm := c.room + switch { + case len(data) == 0: + c.fail("empty-frame", "a binary frame starts with its tag") + case rm == nil: + c.fail("not-in-session", "join or host a session first") + case data[0] == TagUpdate: + if c.role != roleMember { + c.fail("read-only", "a guest suggests changes; only a team member edits") + return + } + cost := len(data) + LogEntryOverhead + if rm.logBytes+cost > c.r.limits.MaxLogBytes || len(rm.log) >= c.r.limits.MaxLogEntries { + c.fail("log-full", "this session's log is full; the host can close it and open a new one") + return + } + _, known := rm.authors[c.num] + if !known && len(rm.authors) >= c.r.limits.MaxCredits { + c.fail("too-many-authors", "this session names as many authors as it may") + return + } + // The update is logged and sent on as the relay attributes it. + entry := c.attribute(TagAttributedUpdate, data) + rm.log = append(rm.log, entry) + rm.logBytes += cost + if !known { + rm.authors[c.num] = c.login + c.r.broadcastRoster(rm) + } + c.r.forward(rm, c, entry) + case data[0] == TagAwareness: + if len(data) > c.r.limits.MaxAwarenessBytes { + c.fail("too-big", fmt.Sprintf("an awareness message is at most %d bytes", c.r.limits.MaxAwarenessBytes)) + return + } + // The connection's own buckets were spent before the lock was + // taken (overRate); the session's is spent here. + if !rm.awareness.takeN(c.r.now(), c.r.limits.AwarenessBytesPerSecond, c.r.limits.AwarenessBytesBurst, len(data)) { + if c.mayAnswer("awareness") { + c.rateLimited("awareness") + } + return + } + // Sent on as the relay attributes it, as an update is, so a page can + // tell whose cursor it is; never logged. + c.r.forward(rm, c, c.attribute(TagAttributedAwareness, data)) + default: + c.fail("unknown-tag", "the relay takes update (0) and awareness (1) frames") + } +} + +// forward sends a binary frame to everyone in rm but from. +func (r *Relay) forward(rm *room, from *Conn, data []byte) { + for _, o := range append([]*Conn(nil), rm.conns...) { + if o != from { + o.send(Frame{Binary: true, Data: data}) + } + } +} + +// repoRe is a GitHub owner/name: an owner of letters, digits and hyphens, a +// name of letters, digits, '.', '_' and '-'. +var repoRe = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$`) + +// ValidRepo reports whether s is an owner/name a URL path may carry. +func ValidRepo(s string) bool { + if !repoRe.MatchString(s) { + return false + } + name := s[strings.IndexByte(s, '/')+1:] + return name != "." && name != ".." +} + +// ownerRe is a GitHub owner: letters, digits and single hyphens between +// them, at most 39 characters. +var ownerRe = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9]|-[A-Za-z0-9]){0,38}$`) + +// ValidOwner reports whether s is a GitHub user or organisation name. +func ValidOwner(s string) bool { return len(s) <= 39 && ownerRe.MatchString(s) } + +// Serves reports whether the relay serves sessions for repo (owner/name). +func (r *Relay) Serves(repo string) bool { + if len(r.repos) == 0 && len(r.owners) == 0 { + return true + } + key := strings.ToLower(repo) + owner, _, _ := strings.Cut(key, "/") + return r.repos[key] || r.owners[owner] +} + +// verify asks the Verifier about a token, outside the lock. +func (c *Conn) verify(ctx context.Context, token, repo string) { + r := c.r + r.mu.Lock() + if c.left { + r.mu.Unlock() + return + } + if c.role == roleMember { + c.fail("already-verified", "this connection is a team member already") + r.mu.Unlock() + return + } + if c.verifies >= r.limits.MaxVerifies { + c.fail("too-many-verifies", "too many sign-in attempts on this connection") + r.mu.Unlock() + return + } + if c.room != nil { + if repo != "" && !strings.EqualFold(repo, c.room.repo) { + c.fail("bad-repo", "a session verifies against its own repository") + r.mu.Unlock() + return + } + repo = c.room.repo + } + if !ValidRepo(repo) { + c.fail("bad-repo", "name the repository as owner/name") + r.mu.Unlock() + return + } + if !r.Serves(repo) { + c.fail("repo-not-served", "this relay serves sessions for other repositories") + r.mu.Unlock() + return + } + if token == "" || r.verifier == nil { + c.fail("verify-failed", "GitHub did not accept the sign-in") + r.mu.Unlock() + return + } + c.verifies++ + r.mu.Unlock() + + login, push, err := r.verifier.Verify(ctx, token, repo) + token = "" // dropped straight after the check + _ = token + + r.mu.Lock() + defer r.mu.Unlock() + if c.left { + return + } + if err != nil || login == "" { + c.fail("verify-failed", "GitHub did not accept the sign-in") + return + } + c.login = login + if push { + c.memberOf = strings.ToLower(repo) + } + member := false + if rm := c.room; rm != nil && push && c.memberOf == rm.repoKey { + c.role, c.name, member = roleMember, login, true + } else if c.room == nil { + member = push + } + c.sendJSON(map[string]any{"type": "verified", "login": login, "member": member}) + if c.room != nil { + r.broadcastRoster(c.room) + } +} + +// host opens a session for repo, which c must be a verified member of. +func (c *Conn) host(repo string) { + r := c.r + // A session past its lifetime holds no place among MaxSessions while it + // waits for the sweep: it ends now. + r.endExpired(r.now()) + switch { + case c.room != nil: + c.fail("already-in-session", "this connection is in a session already") + return + case !ValidRepo(repo): + c.fail("bad-repo", "name the repository as owner/name") + return + case !r.Serves(repo): + c.fail("repo-not-served", "this relay serves sessions for other repositories") + return + case c.login == "": + c.fail("not-verified", "sign in with GitHub before hosting") + return + case c.memberOf != strings.ToLower(repo): + c.fail("not-a-member", "only someone who can change the repository hosts its session") + return + case len(r.rooms) >= r.limits.MaxSessions: + c.fail("too-many-sessions", "this relay holds as many sessions as it may") + return + case r.hosted.blocked(c.addr, r.now()): + c.rateLimited("host") + return + } + code, err := r.newCode() + if err != nil { + c.fail("busy", "no join code could be drawn") + return + } + token, err := r.newRejoinToken() + if err != nil { + c.fail("busy", "no rejoin token could be drawn") + return + } + r.hosted.note(c.addr, r.now()) + rm := &room{repo: repo, repoKey: strings.ToLower(repo), code: code, hostLogin: c.login, opened: r.now(), credits: map[uint32]string{}, authors: map[uint32]string{}} + r.rooms[code] = rm + c.room, c.role, c.name = rm, roleMember, c.login + rm.conns = append(rm.conns, c) + r.giveSeat(c, token) + c.enter() + c.sendJSON(map[string]any{"type": "hosted", "id": c.id, "code": code, "repo": repo, "rejoin": token}) + c.sendJSON(map[string]string{"type": "synced"}) + r.broadcastRoster(rm) +} + +// join admits c to the session code names. +func (c *Conn) join(code, name string) { + r := c.r + if c.room != nil { + c.fail("already-in-session", "this connection is in a session already") + return + } + now := r.now() + if r.failed.blocked(c.addr, now) { + c.fail("too-many-tries", "too many wrong codes from this address; try again later") + c.leave() + c.peer.Close(true, "too many wrong codes") + return + } + // A verified team member is neither paused nor counted towards the + // pause, so guessers cannot lock the team out of its own session. + member := c.memberOf != "" + if !member && now.Before(r.allFailed.pausedUntil) { + // Every code tried during a pause, right or wrong, gets this answer + // without being looked up, and counts for its own address but not + // towards the pause: the addresses that caused it are blocked when + // it ends instead of starting the next. + c.fail("paused", "joining by code is paused on this relay for a while; try again later") + if r.failed.note(c.addr, now) >= r.limits.MaxFailedJoins { + c.leave() + c.peer.Close(true, "too many wrong codes") + } + return + } + rm := r.rooms[normalizeCode(code)] + if rm != nil && r.expired(rm, now) { + // Its time is up, though the sweep has not ended it yet: it ends now, + // and the code is no longer one, without counting as a wrong one. + r.endRoom(rm, "expired") + c.fail("bad-code", "no session has that code") + return + } + if rm == nil { + c.fail("bad-code", "no session has that code") + if !member { + r.noteFailedJoin(now) + } + if r.failed.note(c.addr, now) >= r.limits.MaxFailedJoins { + c.leave() + c.peer.Close(true, "too many wrong codes") + } + return + } + if len(rm.conns) >= r.limits.MaxParticipants { + c.fail("full", "this session has as many participants as it may") + return + } + token, err := r.newRejoinToken() + if err != nil { + c.fail("busy", "no rejoin token could be drawn") + return + } + c.room = rm + if c.login != "" && c.memberOf == rm.repoKey { + c.role, c.name = roleMember, c.login + } else { + c.role, c.name = roleGuest, cleanName(name) + } + rm.conns = append(rm.conns, c) + r.giveSeat(c, token) + c.admitted(token) +} + +// admitted tells c, newly in its room, who it is there, replays the room's +// log to it and announces it. +func (c *Conn) admitted(token string) { + rm := c.room + c.enter() + c.sendJSON(map[string]any{"type": "joined", "id": c.id, "role": c.role, "name": c.name, "repo": rm.repo, "host": c.isHost(), "rejoin": token}) + // The replay is one queued item, not one per entry: a log longer than the + // send queue would otherwise drop every newcomer. The slice is capped at + // its length, so entries logged later never reach this snapshot. + if !c.left && len(rm.log) > 0 && !c.peer.SendLog(rm.log[:len(rm.log):len(rm.log)]) { + c.leave() + c.peer.Close(true, "too slow") + return + } + c.sendJSON(map[string]string{"type": "synced"}) + c.r.broadcastRoster(rm) +} + +// noteFailedJoin counts one wrong code towards the relay-wide budget, and +// pauses joining by code for a window once it is spent. The count starts +// afresh when the pause ends. +func (r *Relay) noteFailedJoin(now time.Time) { + f := &r.allFailed + if now.Sub(f.since) >= r.limits.FailedJoinWindow { + f.count, f.since = 0, now + } + f.count++ + if f.count >= r.limits.RelayFailedJoins { + f.pausedUntil = now.Add(r.limits.FailedJoinWindow) + f.count, f.since = 0, f.pausedUntil + } +} + +// rejoin restores the participant a rejoin token was issued to: the same id, +// role, name and verified login, in the same session, whatever its code is +// now. A connection still holding the token is closed in favour of this one, +// since a device whose network dropped may not have been noticed yet. A token +// no live session holds counts as a wrong code does. +func (c *Conn) rejoin(token string) { + r := c.r + if c.room != nil { + c.fail("already-in-session", "this connection is in a session already") + return + } + now := r.now() + if r.failed.blocked(c.addr, now) { + c.fail("too-many-tries", "too many wrong codes from this address; try again later") + c.leave() + c.peer.Close(true, "too many wrong codes") + return + } + var s *seat + if token != "" && len(token) <= maxRejoinInput { + s = r.seats[rejoinKey(token)] + } + if s != nil && r.expired(s.room, now) { + r.endRoom(s.room, "expired") + c.fail("bad-rejoin", "no session holds that rejoin token") + return + } + if s == nil { + c.fail("bad-rejoin", "no session holds that rejoin token") + if r.failed.note(c.addr, now) >= r.limits.MaxFailedJoins { + c.leave() + c.peer.Close(true, "too many wrong codes") + } + return + } + rm := s.room + if old := s.conn; old != nil && old != c { + old.notify("rejoined-elsewhere", "this participant rejoined on another connection") + old.leave() + old.peer.Close(true, "rejoined on another connection") + } + if len(rm.conns) >= r.limits.MaxParticipants { + c.fail("full", "this session has as many participants as it may") + return + } + c.room, c.seat, s.conn = rm, s, c + c.id, c.num, c.role, c.name, c.login, c.memberOf = s.id, s.num, s.role, s.name, s.login, s.memberOf + rm.conns = append(rm.conns, c) + c.admitted(token) + if c.isHost() { + c.sendJSON(map[string]string{"type": "code", "code": rm.code}) + } +} + +// newRejoinToken draws a rejoin token no seat holds: 128 bits from r.rand, +// base64url without padding. Called under the lock. +func (r *Relay) newRejoinToken() (string, error) { + buf := make([]byte, rejoinBytes) + for attempt := 0; attempt < 4; attempt++ { + if _, err := io.ReadFull(r.rand, buf); err != nil { + return "", err + } + token := base64.RawURLEncoding.EncodeToString(buf) + if _, taken := r.seats[rejoinKey(token)]; !taken { + return token, nil + } + } + return "", errors.New("no free rejoin token") +} + +// giveSeat files c, newly in its room, under token. Past the session's bound, the +// seat of the participant who left longest ago is forgotten; New keeps the +// bound at or above the participant cap, so there always is one. +func (r *Relay) giveSeat(c *Conn, token string) { + rm := c.room + s := &seat{key: rejoinKey(token), room: rm, conn: c} + s.save(c) + c.seat = s + r.seats[s.key] = s + rm.seats = append(rm.seats, s) + for len(rm.seats) > r.limits.MaxRejoinTokens { + i := 0 + for i < len(rm.seats) && rm.seats[i].conn != nil { + i++ + } + if i == len(rm.seats) { + return + } + delete(r.seats, rm.seats[i].key) + rm.seats = append(rm.seats[:i], rm.seats[i+1:]...) + } +} + +// isHost reports whether c may rotate and close: a member signed in as the +// login that opened the session, so a host whose page reloads keeps the role. +func (c *Conn) isHost() bool { + return c.room != nil && c.role == roleMember && c.login == c.room.hostLogin +} + +// suggest routes a guest's proposed change to the team members. Every +// refusal names kind "suggest": the guest's page holds its one suggestion in +// flight until an answer comes. +func (c *Conn) suggest(body json.RawMessage) { + rm, s := c.room, c.seat + switch { + case rm == nil || s == nil: + c.failSuggest("not-in-session", "join or host a session first") + return + case c.role != roleGuest: + c.failSuggest("not-a-guest", "a team member edits directly") + return + case !c.suggests.take(c.r.now(), c.r.limits.SuggestPerSecond, c.r.limits.SuggestBurst): + c.rateLimited("suggest") + return + } + trimmed := strings.TrimSpace(string(body)) + switch { + case len(trimmed) > c.r.limits.MaxSuggestBytes: + c.failSuggest("suggestion-too-big", fmt.Sprintf("a suggestion is at most %d bytes", c.r.limits.MaxSuggestBytes)) + return + case s.suggested >= c.r.limits.MaxSuggestsPerSeat: + c.failSuggest("too-many-suggestions", "you have made as many suggestions as this session takes from one participant") + return + case s.suggestBytes+len(trimmed) > c.r.limits.MaxSuggestBytesPerSeat: + c.failSuggest("participant-suggestions-full", "your suggestions have reached what this session takes from one participant") + return + case rm.suggestBytes+len(trimmed) > c.r.limits.MaxRoomSuggestBytes: + c.failSuggest("suggestions-full", "this session holds as many suggestions as it may") + return + } + var obj map[string]json.RawMessage + if !strings.HasPrefix(trimmed, "{") || json.Unmarshal(body, &obj) != nil { + c.failSuggest("bad-suggestion", "a suggestion's body is one JSON object") + return + } + s.suggested++ + s.suggestBytes += len(trimmed) + rm.suggestBytes += len(trimmed) + rm.suggests++ + id := "s" + strconv.Itoa(rm.suggests) + out := map[string]any{"type": "suggest", "id": id, "from": c.name, "from_id": c.id, "body": json.RawMessage(trimmed)} + delivered := 0 + for _, o := range append([]*Conn(nil), rm.conns...) { + if o.role == roleMember { + o.sendJSON(out) + delivered++ + } + } + c.sendJSON(map[string]any{"type": "suggested", "id": id, "delivered": delivered}) +} + +// register credits a Yjs client id to the member c is signed in as. +func (c *Conn) register(raw json.Number) { + rm := c.room + if rm == nil { + c.fail("not-in-session", "join or host a session first") + return + } + if c.role != roleMember { + c.fail("read-only", "only a team member's edits are credited") + return + } + id, err := strconv.ParseUint(string(raw), 10, 32) + if err != nil { + c.fail("bad-client", "a client id is a whole number from 0 to 4294967295") + return + } + cid := uint32(id) + if owner, ok := rm.credits[cid]; ok { + if owner != c.login { + c.fail("client-taken", "that client id is credited to someone else") + } + return + } + if len(rm.credits) >= c.r.limits.MaxCredits { + c.fail("too-many-clients", "this session credits as many client ids as it may") + return + } + rm.credits[cid] = c.login + c.r.broadcastRoster(rm) +} + +// rotate replaces the session's join code. +func (c *Conn) rotate() { + if !c.isHost() { + c.fail("not-host", "only the host does that") + return + } + rm := c.room + code, err := c.r.newCode() + if err != nil { + c.fail("busy", "no join code could be drawn") + return + } + delete(c.r.rooms, rm.code) + rm.code = code + c.r.rooms[code] = rm + for _, o := range append([]*Conn(nil), rm.conns...) { + if o.isHost() { + o.sendJSON(map[string]string{"type": "code", "code": code}) + } + } +} + +// closeRoom ends the session for everyone and drops all of it. +func (c *Conn) closeRoom() { + if !c.isHost() { + c.fail("not-host", "only the host does that") + return + } + c.r.endRoom(c.room, "host") +} + +// endRoom ends rm for everyone in it, telling each why ("host" for a host's +// close, "expired" past MaxSessionLife), and drops all of it. +func (r *Relay) endRoom(rm *room, reason string) { + conns := append([]*Conn(nil), rm.conns...) + for _, o := range conns { + o.sendJSON(map[string]string{"type": "closed", "reason": reason}) + } + text := "session closed" + if reason == "expired" { + text = "session expired" + } + for _, o := range conns { + // Out of the session at once; each stays counted until its + // transport's Leave. + o.room, o.left = nil, true + o.peer.Close(false, text) + } + rm.conns = nil + r.purge(rm) +} + +// endExpired ends every session that has lived MaxSessionLife. +func (r *Relay) endExpired(now time.Time) { + for _, rm := range r.rooms { + if r.expired(rm, now) { + r.endRoom(rm, "expired") + } + } +} + +// expired reports whether rm has lived MaxSessionLife. +func (r *Relay) expired(rm *room, now time.Time) bool { + return now.Sub(rm.opened) >= r.limits.MaxSessionLife +} + +// purge drops a room and everything it holds. +func (r *Relay) purge(rm *room) { + if r.rooms[rm.code] == rm { + delete(r.rooms, rm.code) + } + for _, s := range rm.seats { + delete(r.seats, s.key) + if s.conn != nil { + s.conn.seat = nil + } + } + rm.log, rm.logBytes, rm.credits, rm.authors, rm.seats = nil, 0, nil, nil, nil +} + +// rosterEntry is one participant as the roster shows them. +type rosterEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Login string `json:"login,omitempty"` + Role string `json:"role"` + Host bool `json:"host,omitempty"` +} + +// broadcastRoster sends rm's roster to everyone in it: the participants with +// their verified identities, the Yjs client ids credited to each member, the +// login of each participant number an attributed update in the log carries +// (authors), including those who have since left, and the login that opened +// the session (host), also while the host is not connected, so a page can +// tell the host's writes to the session's settings from anyone else's. +func (r *Relay) broadcastRoster(rm *room) { + parts := make([]rosterEntry, 0, len(rm.conns)) + for _, o := range rm.conns { + parts = append(parts, rosterEntry{ID: o.id, Name: o.name, Login: o.login, Role: o.role, Host: o.isHost()}) + } + clients := make(map[string]string, len(rm.credits)) + for id, login := range rm.credits { + clients[strconv.FormatUint(uint64(id), 10)] = login + } + authors := make(map[string]string, len(rm.authors)) + for num, login := range rm.authors { + authors[strconv.FormatUint(uint64(num), 10)] = login + } + data, err := json.Marshal(map[string]any{"type": "roster", "participants": parts, "clients": clients, "authors": authors, "host": rm.hostLogin}) + if err != nil { + return + } + for _, o := range append([]*Conn(nil), rm.conns...) { + o.send(Frame{Data: data}) + } +} + +// Sweep ends every session that has lived MaxSessionLife, purges every +// session nobody has been connected to for the idle limit, and forgets +// addresses whose counted events have aged out. The transport calls it on a +// timer. +func (r *Relay) Sweep() { + r.mu.Lock() + defer r.mu.Unlock() + now := r.now() + for _, rm := range r.rooms { + switch { + case r.expired(rm, now): + r.endRoom(rm, "expired") + case len(rm.conns) == 0 && now.Sub(rm.emptySince) >= r.limits.IdleAfter: + r.purge(rm) + } + } + r.failed.sweep(now) + r.hosted.sweep(now) + r.exchanges.sweep(now) +} + +// Stats reports what the relay holds. +func (r *Relay) Stats() Stats { + r.mu.Lock() + defer r.mu.Unlock() + var s Stats + for code, rm := range r.rooms { + s.Sessions++ + if code != "" { + s.Codes++ + } + s.Participants += len(rm.conns) + s.LogBytes += rm.logBytes + s.Credits += len(rm.credits) + } + s.RejoinTokens = len(r.seats) + s.Connections = r.open + return s +} + +// debugState renders every string the relay holds about its sessions and +// connections, for a test to prove what is not among them. +func (r *Relay) debugState() string { + r.mu.Lock() + defer r.mu.Unlock() + var b strings.Builder + for code, rm := range r.rooms { + fmt.Fprintf(&b, "%s %s %s %v\n", code, rm.repo, rm.hostLogin, rm.credits) + for _, s := range rm.seats { + fmt.Fprintf(&b, "seat %s %s %s %s %s\n", s.key, s.id, s.role, s.name, s.login) + } + for _, o := range rm.conns { + fmt.Fprintf(&b, "%+v\n", o) + } + } + return b.String() +} + +// cleanName makes a guest's display name safe to show and to stamp: control +// and format characters (bidirectional overrides among them) removed, spaces +// collapsed, at most 40 characters, and "Guest" when nothing is left. +func cleanName(s string) string { + var b strings.Builder + n := 0 + space := false + for _, r := range strings.TrimSpace(s) { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + continue + } + if unicode.IsSpace(r) { + space = true + continue + } + if space && b.Len() > 0 { + if n >= 39 { + break + } + b.WriteRune(' ') + n++ + } + space = false + if n >= 40 { + break + } + b.WriteRune(r) + n++ + } + if b.Len() == 0 { + return "Guest" + } + return b.String() +} diff --git a/internal/core/session/relay_bounds_test.go b/internal/core/session/relay_bounds_test.go new file mode 100644 index 000000000..667eb3b18 --- /dev/null +++ b/internal/core/session/relay_bounds_test.go @@ -0,0 +1,277 @@ +package session + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// TestAConnectionTheRelayClosesStaysCountedUntilItsTransportLeaves: a +// rejoin, a session's end or any other close the relay asks for takes the +// connection out of its session at once, but its place in the connection +// caps is given back only when the transport's final Leave says the socket is +// gone. A close can wait behind a log replay in progress, so uncounting it at +// the close let a guest who rejoined over and over hold sockets nobody counted +// (final review A). +func TestAConnectionTheRelayClosesStaysCountedUntilItsTransportLeaves(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxConnsPerAddr = 3 }) + _, _, code := h.host(t, "tok-ada") + g1, gp1 := h.join(t, code, "Gus", "198.51.100.7") + tok := rejoinToken(t, gp1, "joined") + + _, gp2 := h.rejoin(t, tok, "198.51.100.7") + if !gp1.closed || gp2.last(t, "joined") == nil { + t.Fatal("the rejoin did not take over the open connection") + } + if s := h.r.Stats(); s.Connections != 3 || s.Participants != 2 { + t.Fatalf("after one rejoin: %+v, want 3 connections (the closed one still held) and 2 participants", s) + } + h.rejoin(t, tok, "198.51.100.7") + // Three sockets from one address are still held: the next is refused, + // however many times the guest rejoins. + if _, err := h.r.Connect(&fakePeer{}, "198.51.100.7"); !errors.Is(err, ErrTooManyConnections) { + t.Fatalf("a fourth connection while three sockets are held: %v, want ErrTooManyConnections", err) + } + // The transport ends the first closed socket, and its place, only its, + // is free. + g1.Leave() + g1.Leave() + if s := h.r.Stats(); s.Connections != 3 { + t.Fatalf("after the first closed socket ended: %d connections, want 3", s.Connections) + } + h.connect(t, &fakePeer{}, "198.51.100.7") + if _, err := h.r.Connect(&fakePeer{}, "198.51.100.7"); !errors.Is(err, ErrTooManyConnections) { + t.Fatal("a place was freed twice") + } + // A closed connection serves nothing while it waits for its transport. + gp1.reset() + send(t, g1, msg{"type": "suggest", "body": msg{"text": "x"}}) + if len(gp1.frames) != 0 { + t.Fatalf("a closed connection was answered: %v", gp1.texts(t)) + } +} + +// TestSuggestionsAreBoundedPerSeat: one participant's suggestions are capped +// in count and in bytes on their seat, so a rejoin does not start them +// afresh and one guest cannot spend the whole session's allowance; the +// session's cap stays as the backstop (final review B). +func TestSuggestionsAreBoundedPerSeat(t *testing.T) { + d := defaultLimits() + if d.MaxSuggestBytesPerSeat != 256<<10 || d.MaxSuggestsPerSeat != 100 || d.MaxSuggestBytesPerSeat >= d.MaxRoomSuggestBytes { + t.Fatalf("seat defaults = %d bytes, %d suggestions; want 256 KiB, 100, under the session's %d", d.MaxSuggestBytesPerSeat, d.MaxSuggestsPerSeat, d.MaxRoomSuggestBytes) + } + delivered := func(p *fakePeer) int { + n := 0 + for _, m := range p.texts(t) { + if m["type"] == "suggest" { + n++ + } + } + return n + } + t.Run("bytes", func(t *testing.T) { + h := newHarness(t, func(l *Limits) { + l.SuggestPerSecond, l.SuggestBurst = 1<<20, 1<<20 + l.MaxSuggestBytesPerSeat = 3000 + }) + _, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + tok := rejoinToken(t, gp, "joined") + body := msg{"replacement": strings.Repeat("y", 1400)} + send(t, g, msg{"type": "suggest", "body": body}) + send(t, g, msg{"type": "suggest", "body": body}) + send(t, g, msg{"type": "suggest", "body": body}) + if e := gp.last(t, "error"); e == nil || e["code"] != "participant-suggestions-full" || e["kind"] != "suggest" || delivered(hp) != 2 { + t.Fatalf("past the seat's bytes: %v, delivered %d; want participant-suggestions-full and 2", e, delivered(hp)) + } + // A rejoin is the same seat. + g2, gp2 := h.rejoin(t, tok, "198.51.100.7") + send(t, g2, msg{"type": "suggest", "body": body}) + if e := gp2.last(t, "error"); e == nil || e["code"] != "participant-suggestions-full" || delivered(hp) != 2 { + t.Fatalf("after a rejoin: %v, delivered %d; want the seat still full", e, delivered(hp)) + } + // Another guest still has their own allowance. + o, op := h.join(t, code, "Ida", "198.51.100.8") + send(t, o, msg{"type": "suggest", "body": body}) + if op.last(t, "suggested") == nil || delivered(hp) != 3 { + t.Fatalf("another guest was held up: %v", op.texts(t)) + } + }) + t.Run("count", func(t *testing.T) { + h := newHarness(t, func(l *Limits) { + l.SuggestPerSecond, l.SuggestBurst = 1<<20, 1<<20 + l.MaxSuggestsPerSeat = 3 + }) + _, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + tok := rejoinToken(t, gp, "joined") + send(t, g, msg{"type": "suggest", "body": msg{"replacement": "x"}}) + send(t, g, msg{"type": "suggest", "body": msg{"replacement": "x"}}) + g2, gp2 := h.rejoin(t, tok, "198.51.100.7") + send(t, g2, msg{"type": "suggest", "body": msg{"replacement": "x"}}) + send(t, g2, msg{"type": "suggest", "body": msg{"replacement": "x"}}) + if e := gp2.last(t, "error"); e == nil || e["code"] != "too-many-suggestions" || delivered(hp) != 3 { + t.Fatalf("the fourth suggestion over a rejoin: %v, delivered %d; want too-many-suggestions and 3", e, delivered(hp)) + } + }) +} + +// TestAwarenessBytesAreRatedPerConnection: each connection has its own byte +// bucket for awareness, below the session's rate, so one participant cannot +// drain the session's and hold up everyone else's cursors (final review C). +func TestAwarenessBytesAreRatedPerConnection(t *testing.T) { + d := defaultLimits() + if d.AwarenessConnBytesPerSecond != 32<<10 || d.AwarenessConnBytesBurst != 64<<10 { + t.Fatalf("connection awareness defaults = %d/s burst %d, want 32 KiB/s burst 64 KiB", d.AwarenessConnBytesPerSecond, d.AwarenessConnBytesBurst) + } + if d.AwarenessConnBytesPerSecond >= d.AwarenessBytesPerSecond || d.AwarenessConnBytesBurst >= d.AwarenessBytesBurst { + t.Fatal("one connection's awareness bucket is not below the session's") + } + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + frame := Frame{Binary: true, Data: append([]byte{TagAwareness}, make([]byte, 8<<10-1)...)} + hp.reset() + for i := 0; i < 9; i++ { + g.Receive(context.Background(), frame) + } + if n := len(hp.binaries()); n != 8 { + t.Fatalf("one connection's burst forwarded %d 8 KiB frames, want 8 (64 KiB)", n) + } + if !rateLimitedAs(t, gp, "awareness") { + t.Fatal("the frame past the connection's byte burst was not refused") + } + // The session's bucket still has room for everyone else. + gp.reset() + host.Receive(context.Background(), frame) + if n := len(gp.binaries()); n != 1 { + t.Fatal("one guest's awareness held up the host's") + } + // A second refills the connection's rate, and no more. + h.clock.advance(time.Second) + hp.reset() + for i := 0; i < 5; i++ { + g.Receive(context.Background(), frame) + } + if n := len(hp.binaries()); n != 4 { + t.Fatalf("a second later %d 8 KiB frames were forwarded, want 4 (32 KiB)", n) + } +} + +// TestRateLimitedFramesAreAnsweredOncePerSecondWithoutTheLock: an update or +// awareness frame over its connection's rate is answered once a second at +// most, the rest dropped silently, and the decision takes no relay lock, so +// a flood of them cannot contend with every other connection. A rate limit +// still counts as no refusal (final review E). +func TestRateLimitedFramesAreAnsweredOncePerSecondWithoutTheLock(t *testing.T) { + h := newHarness(t, nil) + l := h.r.Limits() + host, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + aw := Frame{Binary: true, Data: []byte{TagAwareness, 1}} + limited := func(p *fakePeer, kind string) int { + n := 0 + for _, m := range p.texts(t) { + if m["type"] == "error" && m["code"] == "rate-limited" && m["kind"] == kind { + n++ + } + } + return n + } + gp.reset() + for i := 0; i < l.AwarenessBurst+5000; i++ { + g.Receive(context.Background(), aw) + } + if n := limited(gp, "awareness"); n != 1 || gp.closed { + t.Fatalf("5000 frames over the rate were answered %d times (closed=%v), want once and open", n, gp.closed) + } + // Past its rate a frame is dropped without the relay's lock. + h.r.mu.Lock() + done := make(chan struct{}) + go func() { + g.Receive(context.Background(), aw) + close(done) + }() + select { + case <-done: + h.r.mu.Unlock() + case <-time.After(2 * time.Second): + h.r.mu.Unlock() + <-done + t.Fatal("a frame over its rate waited for the relay's lock") + } + // A second later the next drop is answered again. + h.clock.advance(time.Second) + gp.reset() + for i := 0; i < l.AwarenessPerSecond+10; i++ { + g.Receive(context.Background(), aw) + } + if n := limited(gp, "awareness"); n != 1 { + t.Fatalf("a second later the drops were answered %d times, want once", n) + } + // Updates are answered the same way. + h.clock.advance(time.Second) + hp.reset() + for i := 0; i < l.UpdateBurst+500; i++ { + host.Receive(context.Background(), update(byte(i))) + } + if n := limited(hp, "update"); n != 1 || hp.closed { + t.Fatalf("500 updates over the rate were answered %d times (closed=%v), want once and open", n, hp.closed) + } +} + +// TestARefusedSuggestionIsAlwaysAnsweredWithItsKind: a guest's page holds +// its one suggestion in flight until an answer comes, so every refusal of a +// suggest names kind "suggest", and a suggest dropped past the message budget +// is answered too, once a second (final review F). +func TestARefusedSuggestionIsAlwaysAnsweredWithItsKind(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + outside := &fakePeer{} + oc := h.connect(t, outside, "203.0.113.9") + send(t, oc, msg{"type": "suggest", "body": msg{"text": "x"}}) + if e := outside.last(t, "error"); e == nil || e["code"] != "not-in-session" || e["kind"] != "suggest" { + t.Fatalf("a suggest outside a session: %v, want not-in-session of kind suggest", e) + } + send(t, host, msg{"type": "suggest", "body": msg{"text": "x"}}) + if e := hp.last(t, "error"); e == nil || e["code"] != "not-a-guest" || e["kind"] != "suggest" { + t.Fatalf("a member's suggest: %v, want not-a-guest of kind suggest", e) + } + g, gp := h.join(t, code, "Gus", "198.51.100.7") + send(t, g, msg{"type": "suggest", "body": "not an object"}) + if e := gp.last(t, "error"); e == nil || e["code"] != "bad-suggestion" || e["kind"] != "suggest" { + t.Fatalf("a malformed suggestion: %v, want bad-suggestion of kind suggest", e) + } + // Spend the message budget, then suggest twice. + for i := 0; i < h.r.Limits().MessageBurst+10; i++ { + send(t, g, msg{"type": "nonsense"}) + } + gp.reset() + send(t, g, msg{"type": "suggest", "body": msg{"text": "x"}}) + send(t, g, msg{"type": "suggest", "body": msg{"text": "x"}}) + texts := gp.texts(t) + if len(texts) != 1 || texts[0]["code"] != "rate-limited" || texts[0]["kind"] != "suggest" { + t.Fatalf("two suggests past the message budget were answered %v, want one rate-limited of kind suggest", texts) + } +} + +// TestAnExpiredSessionHoldsNoSessionSlot: a session past its lifetime does +// not count against MaxSessions while it waits for the sweep (final review +// G). +func TestAnExpiredSessionHoldsNoSessionSlot(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxSessions = 1 }) + _, hp, _ := h.host(t, "tok-ada") + h.clock.advance(h.r.Limits().MaxSessionLife) + p := h.hostFrom(t, "198.51.100.20") + if p.last(t, "hosted") == nil { + t.Fatalf("a new session beside an expired one: %v, want hosted", p.texts(t)) + } + if c := hp.last(t, "closed"); c == nil || c["reason"] != "expired" { + t.Fatalf("the expired session's host was not told: %v", hp.last(t, "closed")) + } + if s := h.r.Stats(); s.Sessions != 1 { + t.Fatalf("sessions = %d, want the new one alone", s.Sessions) + } +} diff --git a/internal/core/session/relay_test.go b/internal/core/session/relay_test.go new file mode 100644 index 000000000..26dfd8f27 --- /dev/null +++ b/internal/core/session/relay_test.go @@ -0,0 +1,1803 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// fakePeer is the transport's half of one connection, as a test sees it: every +// frame the relay sent, and whether the relay closed it. +type fakePeer struct { + mu sync.Mutex + frames []Frame + closed bool + policy bool + reason string + full bool + logs int // log replays queued, each one item + // entered counts the times the relay said the connection entered a + // session. + entered int +} + +func (p *fakePeer) Send(f Frame) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.full { + return false + } + p.frames = append(p.frames, Frame{Binary: f.Binary, Data: append([]byte(nil), f.Data...)}) + return true +} + +func (p *fakePeer) SendLog(entries [][]byte) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.full { + return false + } + p.logs++ + for _, e := range entries { + p.frames = append(p.frames, Frame{Binary: true, Data: append([]byte(nil), e...)}) + } + return true +} + +func (p *fakePeer) Entered() { + p.mu.Lock() + defer p.mu.Unlock() + p.entered++ +} + +func (p *fakePeer) Close(policy bool, reason string) { + p.mu.Lock() + defer p.mu.Unlock() + p.closed, p.policy, p.reason = true, policy, reason +} + +// texts decodes every text frame the peer received. +func (p *fakePeer) texts(t *testing.T) []map[string]any { + t.Helper() + p.mu.Lock() + defer p.mu.Unlock() + var out []map[string]any + for _, f := range p.frames { + if f.Binary { + continue + } + var m map[string]any + if err := json.Unmarshal(f.Data, &m); err != nil { + t.Fatalf("a text frame that is not JSON: %q", f.Data) + } + out = append(out, m) + } + return out +} + +// last returns the last text message of type typ, or nil. +func (p *fakePeer) last(t *testing.T, typ string) map[string]any { + t.Helper() + var got map[string]any + for _, m := range p.texts(t) { + if m["type"] == typ { + got = m + } + } + return got +} + +func (p *fakePeer) binaries() [][]byte { + p.mu.Lock() + defer p.mu.Unlock() + var out [][]byte + for _, f := range p.frames { + if f.Binary { + out = append(out, f.Data) + } + } + return out +} + +func (p *fakePeer) reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.frames = nil +} + +// fakeVerifier answers for tokens it was told about: token -> login and the +// repositories that login may push to. +type fakeVerifier struct { + mu sync.Mutex + logins map[string]string + push map[string]map[string]bool + calls int +} + +func (v *fakeVerifier) Verify(_ context.Context, token, repo string) (string, bool, error) { + v.mu.Lock() + defer v.mu.Unlock() + v.calls++ + login, ok := v.logins[token] + if !ok { + return "", false, errors.New("bad credentials") + } + return login, v.push[login][strings.ToLower(repo)], nil +} + +// fakeClock is the injectable clock. +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +type harness struct { + r *Relay + clock *fakeClock + ver *fakeVerifier +} + +func newHarness(t *testing.T, mod func(*Limits)) *harness { + t.Helper() + l := defaultLimits() + // The tests' fake connections never end on their own, so the connection + // caps are lifted unless a test sets them. + l.MaxConnections, l.MaxConnsPerAddr = 1<<20, 1<<20 + if mod != nil { + mod(&l) + } + clock := &fakeClock{now: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)} + ver := &fakeVerifier{ + logins: map[string]string{"tok-ada": "ada", "tok-bea": "bea", "tok-cy": "cy"}, + push: map[string]map[string]bool{ + "ada": {"acme/brief": true}, + "bea": {"acme/brief": true}, + "cy": {}, // signed in, but may not change the repository + }, + } + return &harness{r: New(Options{Limits: l, Verifier: ver, Now: clock.Now}), clock: clock, ver: ver} +} + +// connect admits a connection, failing the test if the relay refuses it. +func (h *harness) connect(t *testing.T, p *fakePeer, addr string) *Conn { + t.Helper() + c, err := h.r.Connect(p, addr) + if err != nil { + t.Fatalf("connect from %s: %v", addr, err) + } + return c +} + +func send(t *testing.T, c *Conn, v any) { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + c.Receive(context.Background(), Frame{Data: data}) +} + +type msg map[string]any + +// host connects a peer, verifies it with token and opens a session for +// acme/brief, returning the connection, its peer and the join code. +func (h *harness) host(t *testing.T, token string) (*Conn, *fakePeer, string) { + t.Helper() + p := &fakePeer{} + c := h.connect(t, p, "192.0.2.1") + send(t, c, msg{"type": "verify", "token": token, "repo": "acme/brief"}) + send(t, c, msg{"type": "host", "repo": "acme/brief"}) + hosted := p.last(t, "hosted") + if hosted == nil { + t.Fatalf("no hosted answer; got %v", p.texts(t)) + } + return c, p, hosted["code"].(string) +} + +func (h *harness) join(t *testing.T, code, name, addr string) (*Conn, *fakePeer) { + t.Helper() + p := &fakePeer{} + c := h.connect(t, p, addr) + send(t, c, msg{"type": "join", "code": code, "name": name}) + return c, p +} + +func update(b ...byte) Frame { return Frame{Binary: true, Data: append([]byte{TagUpdate}, b...)} } + +// attributed is an update as the relay sends it on: tag 2, the sender's +// participant number as four big-endian bytes, then the update's bytes. +func attributed(id string, b ...byte) []byte { + n, err := strconv.ParseUint(strings.TrimPrefix(id, "p"), 10, 32) + if err != nil || !strings.HasPrefix(id, "p") { + panic("not a participant id: " + id) + } + return append([]byte{TagAttributedUpdate, byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}, b...) +} + +// attributedAwareness is an awareness message as the relay sends it on: tag 3, +// the sender's participant number as four big-endian bytes, then the +// message's bytes. +func attributedAwareness(id string, b ...byte) []byte { + out := attributed(id, b...) + out[0] = TagAttributedAwareness + return out +} + +func TestHostOpensASessionAndGetsACode(t *testing.T) { + h := newHarness(t, nil) + _, p, code := h.host(t, "tok-ada") + if len(code) != 6 { + t.Fatalf("code %q is not six characters", code) + } + for _, r := range code { + if !strings.ContainsRune(CodeAlphabet, r) { + t.Fatalf("code %q has %q, outside the alphabet", code, r) + } + } + roster := p.last(t, "roster") + if roster == nil { + t.Fatal("the host got no roster") + } + parts := roster["participants"].([]any) + if len(parts) != 1 || parts[0].(map[string]any)["login"] != "ada" || parts[0].(map[string]any)["host"] != true { + t.Fatalf("roster = %v, want ada alone, as host", roster) + } + if s := h.r.Stats(); s.Sessions != 1 || s.Codes != 1 { + t.Fatalf("stats = %+v, want one session and one code", s) + } +} + +func TestHostingNeedsAVerifiedMember(t *testing.T) { + h := newHarness(t, nil) + for _, c := range []struct { + name string + token string + want string + }{ + {"unverified", "", "not-verified"}, + {"verified without push", "tok-cy", "not-a-member"}, + } { + p := &fakePeer{} + conn := h.connect(t, p, "192.0.2.1") + if c.token != "" { + send(t, conn, msg{"type": "verify", "token": c.token, "repo": "acme/brief"}) + } + send(t, conn, msg{"type": "host", "repo": "acme/brief"}) + if e := p.last(t, "error"); e == nil || e["code"] != c.want { + t.Errorf("%s: error = %v, want %s", c.name, e, c.want) + } + } + if s := h.r.Stats(); s.Sessions != 0 { + t.Fatalf("stats = %+v, want no session", s) + } +} + +func TestCodesAreDrawnFromTheAlphabet(t *testing.T) { + r := New(Options{Rand: bytes.NewReader(bytes.Repeat([]byte{0, 30, 247, 248, 255, 31}, 10))}) + code, err := r.newCode() + if err != nil { + t.Fatal(err) + } + // 0 -> A, 30 -> 9, 247 -> 247%31 = 30 -> 9, 248 and 255 rejected, 31 -> A. + if code != "A99AA9" { + t.Fatalf("code = %q, want A99AA9 (bytes 248 and above are rejected, not folded)", code) + } +} + +func TestGuestJoinsByCodeAndReceivesTheReplayedLog(t *testing.T) { + h := newHarness(t, nil) + host, _, code := h.host(t, "tok-ada") + host.Receive(context.Background(), update(1, 2, 3)) + host.Receive(context.Background(), update(4, 5)) + + // The code is read loosely, as a person types it. + _, gp := h.join(t, strings.ToLower(code[:3])+"-"+strings.ToLower(code[3:]), "Gus", "198.51.100.7") + joined := gp.last(t, "joined") + if joined == nil || joined["role"] != "guest" || joined["name"] != "Gus" || joined["repo"] != "acme/brief" { + t.Fatalf("joined = %v, want a guest named Gus in acme/brief", joined) + } + got := gp.binaries() + if len(got) != 2 || !bytes.Equal(got[0], attributed(host.id, 1, 2, 3)) || !bytes.Equal(got[1], attributed(host.id, 4, 5)) { + t.Fatalf("replayed = %v, want the two updates in order", got) + } + // The replay sits between joined and synced. + var order []string + gp.mu.Lock() + for _, f := range gp.frames { + if f.Binary { + order = append(order, "update") + continue + } + var m map[string]any + _ = json.Unmarshal(f.Data, &m) + order = append(order, m["type"].(string)) + } + gp.mu.Unlock() + if strings.Join(order, ",") != "joined,update,update,synced,roster" { + t.Fatalf("order = %v", order) + } +} + +func TestGuestUpdateIsRefusedWhileAMembersIsForwarded(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + guest, gp := h.join(t, code, "Gus", "198.51.100.7") + hp.reset() + gp.reset() + + guest.Receive(context.Background(), update(9, 9)) + if e := gp.last(t, "error"); e == nil || e["code"] != "read-only" { + t.Fatalf("guest's update: error = %v, want read-only", e) + } + if len(hp.binaries()) != 0 { + t.Fatal("a guest's update reached the host") + } + if s := h.r.Stats(); s.LogBytes != 0 { + t.Fatalf("a guest's update was logged: %+v", s) + } + + host.Receive(context.Background(), update(7)) + if got := gp.binaries(); len(got) != 1 || !bytes.Equal(got[0], attributed(host.id, 7)) { + t.Fatalf("the guest received %v, want the member's update", got) + } + if len(hp.binaries()) != 0 { + t.Fatal("the sender's own update was echoed back") + } + + // Awareness is forwarded from anyone, attributed, and never logged. + guest.Receive(context.Background(), Frame{Binary: true, Data: []byte{TagAwareness, 1}}) + if got := hp.binaries(); len(got) != 1 || !bytes.Equal(got[0], attributedAwareness(guest.id, 1)) { + t.Fatalf("the host received %v, want the guest's awareness", got) + } + if s := h.r.Stats(); s.LogBytes != LogEntryOverhead+2 { + t.Fatalf("log bytes = %d, want %d (the one update)", s.LogBytes, LogEntryOverhead+2) + } +} + +func TestVerifyPromotesATokenWithPushAndNotOneWithout(t *testing.T) { + h := newHarness(t, nil) + _, hp, code := h.host(t, "tok-ada") + g1, p1 := h.join(t, code, "Bea", "198.51.100.7") + g2, p2 := h.join(t, code, "Cy", "198.51.100.8") + + send(t, g1, msg{"type": "verify", "token": "tok-bea"}) + if v := p1.last(t, "verified"); v == nil || v["login"] != "bea" || v["member"] != true { + t.Fatalf("bea: verified = %v, want a member", v) + } + send(t, g2, msg{"type": "verify", "token": "tok-cy"}) + if v := p2.last(t, "verified"); v == nil || v["login"] != "cy" || v["member"] != false { + t.Fatalf("cy: verified = %v, want not a member", v) + } + send(t, g2, msg{"type": "verify", "token": "tok-nobody"}) + if e := p2.last(t, "error"); e == nil || e["code"] != "verify-failed" { + t.Fatalf("a bad token: error = %v, want verify-failed", e) + } + + roles := map[string]string{} + for _, e := range hp.last(t, "roster")["participants"].([]any) { + m := e.(map[string]any) + roles[m["name"].(string)] = m["role"].(string) + } + if roles["bea"] != "member" || roles["Cy"] != "guest" { + t.Fatalf("roster roles = %v, want bea a member and Cy a guest", roles) + } + + // The promoted member's update now goes through; cy's still does not. + hp.reset() + g1.Receive(context.Background(), update(1)) + g2.Receive(context.Background(), update(2)) + if got := hp.binaries(); len(got) != 1 || !bytes.Equal(got[0], attributed(g1.id, 1)) { + t.Fatalf("host received %v, want bea's update alone", got) + } + + // The relay holds no token: nothing it can report carries one. + if strings.Contains(h.r.debugState(), "tok-") { + t.Fatal("the relay kept a token after verifying it") + } +} + +func TestVerifyIsBoundedPerConnection(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxVerifies = 2 }) + _, _, code := h.host(t, "tok-ada") + g, p := h.join(t, code, "Cy", "198.51.100.8") + for i := 0; i < 3; i++ { + send(t, g, msg{"type": "verify", "token": "tok-cy"}) + } + if e := p.last(t, "error"); e == nil || e["code"] != "too-many-verifies" { + t.Fatalf("error = %v, want too-many-verifies", e) + } + if h.ver.calls != 1+2 { // the host's own, then two of cy's + t.Fatalf("GitHub was asked %d times, want 3", h.ver.calls) + } +} + +func TestSuggestionGoesOnlyToMembersStampedWithTheGuestsName(t *testing.T) { + h := newHarness(t, nil) + _, hp, code := h.host(t, "tok-ada") + m2, mp := h.join(t, code, "", "198.51.100.6") + send(t, m2, msg{"type": "verify", "token": "tok-bea"}) + g, gp := h.join(t, code, "Gus", "198.51.100.7") + _, op := h.join(t, code, "Other", "198.51.100.9") + hp.reset() + mp.reset() + op.reset() + + send(t, g, msg{"type": "suggest", "name": "ada", "body": msg{"path": "brief.md", "text": "better words"}}) + for who, p := range map[string]*fakePeer{"host": hp, "member": mp} { + s := p.last(t, "suggest") + if s == nil || s["from"] != "Gus" { + t.Fatalf("%s: suggest = %v, want one stamped from Gus", who, s) + } + if body := s["body"].(map[string]any); body["text"] != "better words" { + t.Fatalf("%s: body = %v", who, body) + } + } + if s := op.last(t, "suggest"); s != nil { + t.Fatalf("another guest received a suggestion: %v", s) + } + if a := gp.last(t, "suggested"); a == nil || a["id"] == "" { + t.Fatalf("the guest got no receipt: %v", gp.texts(t)) + } + + // A suggestion that is not an object is refused; a member does not suggest. + send(t, g, msg{"type": "suggest", "body": "text"}) + if e := gp.last(t, "error"); e == nil || e["code"] != "bad-suggestion" { + t.Fatalf("error = %v, want bad-suggestion", e) + } + send(t, m2, msg{"type": "suggest", "body": msg{}}) + if e := mp.last(t, "error"); e == nil || e["code"] != "not-a-guest" { + t.Fatalf("error = %v, want not-a-guest", e) + } +} + +func TestGuestNameIsCleaned(t *testing.T) { + h := newHarness(t, nil) + _, _, code := h.host(t, "tok-ada") + _, p := h.join(t, code, " Gus‮\n"+strings.Repeat("x", 100), "198.51.100.7") + name := p.last(t, "joined")["name"].(string) + if strings.ContainsAny(name, "‮\n") || len([]rune(name)) > 40 || !strings.HasPrefix(name, "Gus") { + t.Fatalf("name = %q, want it trimmed, without control or format characters, at most 40", name) + } + _, p2 := h.join(t, code, "​", "198.51.100.8") + if n := p2.last(t, "joined")["name"]; n != "Guest" { + t.Fatalf("an empty name became %q, want Guest", n) + } +} + +func TestWrongCodesAreLimitedPerAddress(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxFailedJoins = 3 }) + _, _, code := h.host(t, "tok-ada") + var last *fakePeer + for i := 0; i < 3; i++ { + _, last = h.join(t, "ZZZZZZ", "Mal", "203.0.113.5") + if e := last.last(t, "error"); e == nil || e["code"] != "bad-code" { + t.Fatalf("try %d: error = %v, want bad-code", i, e) + } + } + if !last.closed || !last.policy { + t.Fatal("the third wrong code did not close the connection") + } + // Past the limit, even the right code is refused from that address, and + // the answer tells nothing about the code. + _, p := h.join(t, code, "Mal", "203.0.113.5") + if e := p.last(t, "error"); e == nil || e["code"] != "too-many-tries" || !p.closed { + t.Fatalf("error = %v, want too-many-tries and a close", e) + } + // An IPv6 visitor is counted by its /48: a site holds 65,536 /64s. + for i := 0; i < 3; i++ { + h.join(t, "ZZZZZZ", "Mal", "2001:db8:1:"+string(rune('a'+i))+"::1") + } + if _, p := h.join(t, code, "Mal", "2001:db8:1:ff::1"); p.last(t, "error")["code"] != "too-many-tries" { + t.Fatal("rotating networks inside one /48 escaped the limit") + } + // Another address is not held up, and the window passes. + if _, p := h.join(t, code, "Gus", "198.51.100.7"); p.last(t, "joined") == nil { + t.Fatal("another address was held up by the limit") + } + h.clock.advance(defaultLimits().FailedJoinWindow + time.Second) + if _, p := h.join(t, code, "Mal", "203.0.113.5"); p.last(t, "joined") == nil { + t.Fatal("the limit outlived its window") + } +} + +func TestSizeLimits(t *testing.T) { + h := newHarness(t, func(l *Limits) { + l.MaxMessageBytes = 64 + l.MaxLogBytes = LogEntryOverhead + 12 + l.MaxParticipants = 2 + l.MaxSessions = 1 + }) + host, hp, code := h.host(t, "tok-ada") + + host.Receive(context.Background(), Frame{Binary: true, Data: append([]byte{TagUpdate}, make([]byte, 64)...)}) + if e := hp.last(t, "error"); e == nil || e["code"] != "too-big" { + t.Fatalf("an oversized frame: error = %v, want too-big", e) + } + host.Receive(context.Background(), update(make([]byte, 8)...)) + host.Receive(context.Background(), update(1, 2)) + if e := hp.last(t, "error"); e == nil || e["code"] != "log-full" { + t.Fatalf("past the log limit: error = %v, want log-full", e) + } + if s := h.r.Stats(); s.LogBytes != LogEntryOverhead+9 { + t.Fatalf("log bytes = %d, want %d (the one update and its overhead)", s.LogBytes, LogEntryOverhead+9) + } + + h.join(t, code, "One", "198.51.100.7") + if _, p := h.join(t, code, "Two", "198.51.100.8"); p.last(t, "error")["code"] != "full" { + t.Fatal("a third participant got into a session of two") + } + + // One session per relay: a second host is refused. + p := &fakePeer{} + c := h.connect(t, p, "192.0.2.2") + send(t, c, msg{"type": "verify", "token": "tok-bea", "repo": "acme/brief"}) + send(t, c, msg{"type": "host", "repo": "acme/brief"}) + if e := p.last(t, "error"); e == nil || e["code"] != "too-many-sessions" { + t.Fatalf("error = %v, want too-many-sessions", e) + } +} + +func TestOnlyTheHostRotatesAndCloses(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + send(t, g, msg{"type": "rotate"}) + send(t, g, msg{"type": "close"}) + if e := gp.last(t, "error"); e == nil || e["code"] != "not-host" { + t.Fatalf("error = %v, want not-host", e) + } + send(t, host, msg{"type": "rotate"}) + next := hp.last(t, "code") + if next == nil || next["code"] == code { + t.Fatalf("rotate answered %v, want a new code", next) + } + if _, p := h.join(t, code, "Late", "198.51.100.8"); p.last(t, "error")["code"] != "bad-code" { + t.Fatal("the old code still works after a rotation") + } + if _, p := h.join(t, next["code"].(string), "Late", "198.51.100.8"); p.last(t, "joined") == nil { + t.Fatal("the new code does not work") + } +} + +func TestHostClosePurgesTheSession(t *testing.T) { + h := newHarness(t, nil) + host, _, code := h.host(t, "tok-ada") + send(t, host, msg{"type": "register", "client": 12345}) + host.Receive(context.Background(), update(1, 2, 3)) + _, gp := h.join(t, code, "Gus", "198.51.100.7") + + send(t, host, msg{"type": "close"}) + if c := gp.last(t, "closed"); c == nil || !gp.closed { + t.Fatalf("the guest was not told and closed: %v", gp.texts(t)) + } + // Nothing of the session is held; the two sockets are counted until + // their transports end them. + if s := h.r.Stats(); s != (Stats{Connections: 2}) { + t.Fatalf("after the host closed, the relay still holds %+v", s) + } + if _, p := h.join(t, code, "Late", "198.51.100.8"); p.last(t, "error")["code"] != "bad-code" { + t.Fatal("a closed session's code still works") + } +} + +func TestIdleSessionIsPurged(t *testing.T) { + h := newHarness(t, nil) + host, _, code := h.host(t, "tok-ada") + host.Receive(context.Background(), update(1)) + g, _ := h.join(t, code, "Gus", "198.51.100.7") + host.Leave() + h.clock.advance(time.Hour) + h.r.Sweep() + if s := h.r.Stats(); s.Sessions != 1 { + t.Fatal("a session with someone still connected was purged") + } + g.Leave() + h.clock.advance(defaultLimits().IdleAfter - time.Second) + h.r.Sweep() + if s := h.r.Stats(); s.Sessions != 1 { + t.Fatal("purged before the idle limit") + } + h.clock.advance(2 * time.Second) + h.r.Sweep() + if s := h.r.Stats(); s != (Stats{}) { + t.Fatalf("after the idle limit, the relay still holds %+v", s) + } +} + +func TestHostRejoiningByLoginKeepsTheHostRole(t *testing.T) { + h := newHarness(t, nil) + host, _, code := h.host(t, "tok-ada") + host.Leave() + p := &fakePeer{} + c := h.connect(t, p, "192.0.2.1") + send(t, c, msg{"type": "verify", "token": "tok-ada", "repo": "acme/brief"}) + send(t, c, msg{"type": "join", "code": code}) + if j := p.last(t, "joined"); j == nil || j["role"] != "member" || j["host"] != true { + t.Fatalf("joined = %v, want the host back as a member and host", j) + } + send(t, c, msg{"type": "rotate"}) + if p.last(t, "code") == nil { + t.Fatal("the returning host could not rotate") + } +} + +func TestRegisterCreditsAClientToAMember(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + m, mp := h.join(t, code, "", "198.51.100.6") + send(t, m, msg{"type": "verify", "token": "tok-bea"}) + g, gp := h.join(t, code, "Gus", "198.51.100.7") + + send(t, host, msg{"type": "register", "client": 4294967295}) + clients := hp.last(t, "roster")["clients"].(map[string]any) + if clients["4294967295"] != "ada" { + t.Fatalf("clients = %v, want the id credited to ada", clients) + } + send(t, m, msg{"type": "register", "client": 4294967295}) + if e := mp.last(t, "error"); e == nil || e["code"] != "client-taken" { + t.Fatalf("error = %v, want client-taken", e) + } + send(t, g, msg{"type": "register", "client": 7}) + if e := gp.last(t, "error"); e == nil || e["code"] != "read-only" { + t.Fatalf("a guest registered a client: %v", e) + } + send(t, host, msg{"type": "register", "client": -1}) + if e := hp.last(t, "error"); e == nil || e["code"] != "bad-client" { + t.Fatalf("error = %v, want bad-client", e) + } +} + +func TestMalformedMessagesAreRefused(t *testing.T) { + h := newHarness(t, nil) + p := &fakePeer{} + c := h.connect(t, p, "192.0.2.1") + for _, raw := range []string{`not json`, `{"type":"dance"}`, `[]`} { + c.Receive(context.Background(), Frame{Data: []byte(raw)}) + } + var codes []string + for _, m := range p.texts(t) { + codes = append(codes, m["code"].(string)) + } + if strings.Join(codes, ",") != "bad-message,unknown-message,bad-message" { + t.Fatalf("codes = %v", codes) + } + c.Receive(context.Background(), update(1)) + if e := p.last(t, "error"); e["code"] != "not-in-session" { + t.Fatalf("error = %v, want not-in-session", e) + } + send(t, c, msg{"type": "verify", "token": "tok-ada", "repo": "../etc"}) + if e := p.last(t, "error"); e["code"] != "bad-repo" { + t.Fatalf("error = %v, want bad-repo", e) + } +} + +func TestSlowPeerIsDropped(t *testing.T) { + h := newHarness(t, nil) + host, _, code := h.host(t, "tok-ada") + _, gp := h.join(t, code, "Gus", "198.51.100.7") + gp.mu.Lock() + gp.full = true + gp.mu.Unlock() + host.Receive(context.Background(), update(1)) + if !gp.closed { + t.Fatal("a peer whose queue is full was not closed") + } + if s := h.r.Stats(); s.Participants != 1 { + t.Fatalf("participants = %d, want the slow peer gone", s.Participants) + } +} + +// TestAddressKeyCountsATailnetAddressAlone: Tailscale gives each device one +// address, and every device of a tailnet shares a /64 of its IPv6 range, so a +// tailnet address is its own key while other IPv6 addresses are counted by /64. +func TestAddressKeyCountsATailnetAddressAlone(t *testing.T) { + for in, want := range map[string]string{ + "fd7a:115c:a1e0::a1": "fd7a:115c:a1e0::a1", // abcd-lint:allow: a tailnet address, the shape under test + "fd7a:115c:a1e0:ab12:4843:cd96:6258:b240": "fd7a:115c:a1e0:ab12:4843:cd96:6258:b240", // abcd-lint:allow: a tailnet address, the shape under test + "100.101.102.103": "100.101.102.103", // abcd-lint:allow: a tailnet address, the shape under test + "2001:db8:1:2::a": "2001:db8:1::/48", + "2001:db8:1:ffff::a": "2001:db8:1::/48", + "::ffff:192.0.2.1": "192.0.2.1", + } { + if got := addressKey(in); got != want { + t.Errorf("addressKey(%q) = %q, want %q", in, got, want) + } + } +} + +// hostFrom connects at addr, verifies as ada and asks to host acme/brief, +// returning the peer. +func (h *harness) hostFrom(t *testing.T, addr string) *fakePeer { + t.Helper() + p := &fakePeer{} + c := h.connect(t, p, addr) + send(t, c, msg{"type": "verify", "token": "tok-ada", "repo": "acme/brief"}) + send(t, c, msg{"type": "host", "repo": "acme/brief"}) + return p +} + +// rateLimited reports whether p's last error is rate-limited, with p still +// open: an answer over the limit is dropped, not the connection. +func rateLimited(t *testing.T, p *fakePeer) bool { + t.Helper() + e := p.last(t, "error") + return e != nil && e["code"] == "rate-limited" && !p.closed +} + +// rateLimitedAs reports whether p's last error is rate-limited for a message +// of kind, so a page can tell a dropped cursor from a dropped suggestion. +func rateLimitedAs(t *testing.T, p *fakePeer, kind string) bool { + t.Helper() + e := p.last(t, "error") + return rateLimited(t, p) && e["kind"] == kind +} + +func TestAwarenessIsRateLimitedPerConnection(t *testing.T) { + h := newHarness(t, nil) + l := defaultLimits() + if l.AwarenessPerSecond != 20 || l.AwarenessBurst != 40 { + t.Fatalf("awareness default = %d/s burst %d, want 20/s burst 40", l.AwarenessPerSecond, l.AwarenessBurst) + } + hc, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + hp.reset() + gp.reset() + aw := Frame{Binary: true, Data: []byte{TagAwareness, 1}} + for i := 0; i < l.AwarenessBurst; i++ { + g.Receive(context.Background(), aw) + } + if n := len(hp.binaries()); n != l.AwarenessBurst { + t.Fatalf("the burst forwarded %d frames, want %d", n, l.AwarenessBurst) + } + if e := gp.last(t, "error"); e != nil { + t.Fatalf("an error inside the burst: %v", e) + } + g.Receive(context.Background(), aw) + if n := len(hp.binaries()); n != l.AwarenessBurst { + t.Fatal("a frame past the burst was forwarded") + } + if !rateLimitedAs(t, gp, "awareness") { + t.Fatalf("past the burst: %v, closed=%v; want rate-limited and still open", gp.last(t, "error"), gp.closed) + } + // A second refills the rate, and no more. + h.clock.advance(time.Second) + hp.reset() + gp.reset() + for i := 0; i < l.AwarenessPerSecond+1; i++ { + g.Receive(context.Background(), aw) + } + if n := len(hp.binaries()); n != l.AwarenessPerSecond { + t.Fatalf("a second later %d frames were forwarded, want %d", n, l.AwarenessPerSecond) + } + if !rateLimitedAs(t, gp, "awareness") { + t.Fatal("the frame past the refill was not refused") + } + // Each connection has its own bucket. + gp.reset() + hc.Receive(context.Background(), aw) + if len(gp.binaries()) != 1 { + t.Fatal("the host's awareness was held up by the guest's") + } +} + +func TestSuggestIsRateLimitedPerConnection(t *testing.T) { + h := newHarness(t, nil) + l := defaultLimits() + if l.SuggestPerSecond != 1 || l.SuggestBurst != 5 { + t.Fatalf("suggest default = %d/s burst %d, want 1/s burst 5", l.SuggestPerSecond, l.SuggestBurst) + } + _, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + count := func() int { + n := 0 + for _, m := range hp.texts(t) { + if m["type"] == "suggest" { + n++ + } + } + return n + } + for i := 0; i < l.SuggestBurst+1; i++ { + send(t, g, msg{"type": "suggest", "body": msg{"text": "x"}}) + } + if n := count(); n != l.SuggestBurst { + t.Fatalf("%d suggestions delivered, want the burst of %d", n, l.SuggestBurst) + } + if !rateLimitedAs(t, gp, "suggest") { + t.Fatalf("past the burst: %v, closed=%v; want rate-limited and still open", gp.last(t, "error"), gp.closed) + } + h.clock.advance(time.Second) + send(t, g, msg{"type": "suggest", "body": msg{"text": "y"}}) + if n := count(); n != l.SuggestBurst+1 { + t.Fatal("a second later the next suggestion was not delivered") + } + gp.reset() + send(t, g, msg{"type": "suggest", "body": msg{"text": "z"}}) + if !rateLimitedAs(t, gp, "suggest") || count() != l.SuggestBurst+1 { + t.Fatal("the suggestion past the refill was not refused") + } +} + +func TestHostingIsRateLimitedPerAddress(t *testing.T) { + h := newHarness(t, nil) + l := defaultLimits() + if l.MaxHostsPerAddr != 3 || l.HostWindow != 10*time.Minute { + t.Fatalf("host default = %d per %v, want 3 per 10m", l.MaxHostsPerAddr, l.HostWindow) + } + // A refused host does not count towards the limit. + p := &fakePeer{} + c := h.connect(t, p, "203.0.113.5") + send(t, c, msg{"type": "host", "repo": "acme/brief"}) + if e := p.last(t, "error"); e == nil || e["code"] != "not-verified" { + t.Fatalf("unverified host: %v", e) + } + for i := 0; i < l.MaxHostsPerAddr; i++ { + if p := h.hostFrom(t, "203.0.113.5"); p.last(t, "hosted") == nil { + t.Fatalf("session %d was not opened: %v", i, p.texts(t)) + } + } + p = h.hostFrom(t, "203.0.113.5") + if p.last(t, "hosted") != nil || !rateLimited(t, p) { + t.Fatalf("a fourth session from one address: %v, closed=%v; want rate-limited and still open", p.texts(t), p.closed) + } + if h.r.Stats().Sessions != l.MaxHostsPerAddr { + t.Fatalf("sessions = %d, want %d", h.r.Stats().Sessions, l.MaxHostsPerAddr) + } + if p := h.hostFrom(t, "198.51.100.7"); p.last(t, "hosted") == nil { + t.Fatal("another address was held up by the limit") + } + h.clock.advance(l.HostWindow) + if p := h.hostFrom(t, "203.0.113.5"); p.last(t, "hosted") == nil { + t.Fatal("the limit outlived its window") + } +} + +// rejoinToken returns the rejoin token a hosted or joined answer carried. +func rejoinToken(t *testing.T, p *fakePeer, typ string) string { + t.Helper() + m := p.last(t, typ) + if m == nil { + t.Fatalf("no %s answer; got %v", typ, p.texts(t)) + } + tok, _ := m["rejoin"].(string) + // 128 random bits, base64url without padding. + if len(tok) != 22 || strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" { + t.Fatalf("%s carried rejoin token %q, want 22 base64url characters", typ, tok) + } + return tok +} + +func (h *harness) rejoin(t *testing.T, token, addr string) (*Conn, *fakePeer) { + t.Helper() + p := &fakePeer{} + c := h.connect(t, p, addr) + send(t, c, msg{"type": "rejoin", "token": token}) + return c, p +} + +func TestRejoinAfterARotationKeepsTheParticipant(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + hostTok := rejoinToken(t, hp, "hosted") + host.Receive(context.Background(), update(1, 2)) + guest, gp := h.join(t, code, "Gus", "198.51.100.7") + guestTok := rejoinToken(t, gp, "joined") + guestID := gp.last(t, "joined")["id"] + member, mp := h.join(t, code, "", "198.51.100.8") + send(t, member, msg{"type": "verify", "token": "tok-bea"}) + memberTok := rejoinToken(t, mp, "joined") + if hostTok == guestTok || guestTok == memberTok { + t.Fatal("two participants were given the same rejoin token") + } + + send(t, host, msg{"type": "rotate"}) + if next := hp.last(t, "code"); next == nil || next["code"] == code { + t.Fatal("the code did not rotate") + } + guest.Leave() + member.Leave() + host.Leave() + + // The guest comes back with the token alone: same id, role and name, the + // log replayed, and no code needed. + _, gp2 := h.rejoin(t, guestTok, "198.51.100.9") + j := gp2.last(t, "joined") + if j == nil || j["role"] != "guest" || j["name"] != "Gus" || j["id"] != guestID || j["host"] == true { + t.Fatalf("guest rejoined = %v, want guest Gus as %v", j, guestID) + } + if got := gp2.binaries(); len(got) != 1 || got[0][0] != TagAttributedUpdate || !bytes.Equal(got[0][5:], []byte{1, 2}) { + t.Fatalf("the rejoined guest was replayed %v", got) + } + if gp2.last(t, "synced") == nil { + t.Fatal("the rejoined guest was not told it is synced") + } + + // The member comes back a member, with the login GitHub verified. + m2, mp2 := h.rejoin(t, memberTok, "198.51.100.10") + if j := mp2.last(t, "joined"); j == nil || j["role"] != "member" || j["name"] != "bea" || j["host"] == true { + t.Fatalf("member rejoined = %v, want member bea", j) + } + m2.Receive(context.Background(), update(3)) + if e := mp2.last(t, "error"); e != nil { + t.Fatalf("the rejoined member cannot edit: %v", e) + } + + // The host comes back the host, and is told the current code. + h2, hp2 := h.rejoin(t, hostTok, "192.0.2.1") + if j := hp2.last(t, "joined"); j == nil || j["role"] != "member" || j["host"] != true { + t.Fatalf("host rejoined = %v, want the host", j) + } + if c := hp2.last(t, "code"); c == nil || c["code"] == code { + t.Fatalf("the rejoined host was not told the current code: %v", c) + } + send(t, h2, msg{"type": "rotate"}) + if e := hp2.last(t, "error"); e != nil { + t.Fatalf("the rejoined host cannot rotate: %v", e) + } + roster := hp2.last(t, "roster")["participants"].([]any) + if len(roster) != 3 { + t.Fatalf("roster = %v, want the three back", roster) + } +} + +func TestRejoinTakesOverAConnectionStillOpen(t *testing.T) { + h := newHarness(t, nil) + _, _, code := h.host(t, "tok-ada") + _, gp := h.join(t, code, "Gus", "198.51.100.7") + tok := rejoinToken(t, gp, "joined") + _, gp2 := h.rejoin(t, tok, "198.51.100.7") + if gp2.last(t, "joined") == nil { + t.Fatalf("rejoin over an open connection: %v", gp2.texts(t)) + } + if !gp.closed || !gp.policy { + t.Fatal("the connection the token was taken from was left open") + } + roster := gp2.last(t, "roster")["participants"].([]any) + if len(roster) != 2 { + t.Fatalf("roster = %v, want the host and one Gus", roster) + } +} + +func TestForgedRejoinTokensAreRefusedAndLimited(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxFailedJoins = 3 }) + _, _, code := h.host(t, "tok-ada") + _, gp := h.join(t, code, "Gus", "198.51.100.7") + real := rejoinToken(t, gp, "joined") + + var last *fakePeer + for i, forged := range []string{"AAAAAAAAAAAAAAAAAAAAAA", real[:21] + "x", strings.Repeat("y", 5000)} { + _, last = h.rejoin(t, forged, "203.0.113.5") + if e := last.last(t, "error"); e == nil || e["code"] != "bad-rejoin" || last.last(t, "joined") != nil { + t.Fatalf("forged token %d: error = %v, want bad-rejoin", i, e) + } + } + if !last.closed || !last.policy { + t.Fatal("the third failed rejoin did not close the connection") + } + // Past the limit even the real token is refused from that address, as a + // right code is after too many wrong ones. + if _, p := h.rejoin(t, real, "203.0.113.5"); p.last(t, "error")["code"] != "too-many-tries" || !p.closed { + t.Fatal("failed rejoins are not limited like wrong codes") + } + // Wrong codes and forged tokens share one count. + h.join(t, "ZZZZZZ", "Mal", "203.0.113.6") + h.join(t, "ZZZZZZ", "Mal", "203.0.113.6") + if _, p := h.rejoin(t, "AAAAAAAAAAAAAAAAAAAAAA", "203.0.113.6"); !p.closed { + t.Fatal("wrong codes and forged tokens are counted apart") + } + if s := h.r.Stats(); s.RejoinTokens != 2 { + t.Fatalf("stats = %+v, want the two real tokens", s) + } +} + +func TestRejoinTokenDiesWithItsSession(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + hostTok := rejoinToken(t, hp, "hosted") + _, gp := h.join(t, code, "Gus", "198.51.100.7") + tok := rejoinToken(t, gp, "joined") + send(t, host, msg{"type": "close"}) + if s := h.r.Stats(); s != (Stats{Connections: 2}) { + t.Fatalf("after the host closed, the relay still holds %+v", s) + } + for _, tk := range []string{tok, hostTok} { + if _, p := h.rejoin(t, tk, "198.51.100.7"); p.last(t, "error")["code"] != "bad-rejoin" { + t.Fatal("a closed session's rejoin token still works") + } + } + + // And with a session purged for idleness. + host2, hp2, _ := h.host(t, "tok-bea") + tok2 := rejoinToken(t, hp2, "hosted") + host2.Leave() + h.clock.advance(defaultLimits().IdleAfter + time.Second) + h.r.Sweep() + if _, p := h.rejoin(t, tok2, "192.0.2.1"); p.last(t, "error")["code"] != "bad-rejoin" { + t.Fatal("a purged session's rejoin token still works") + } + if s := h.r.Stats(); s.RejoinTokens != 0 { + t.Fatalf("stats = %+v, want no token held", s) + } +} + +func TestRejoinTokensAreBoundedPerSession(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxParticipants = 2; l.MaxRejoinTokens = 3 }) + _, _, code := h.host(t, "tok-ada") + var toks []string + for i := 0; i < 4; i++ { + g, p := h.join(t, code, "G", "198.51.100.7") + toks = append(toks, rejoinToken(t, p, "joined")) + g.Leave() + } + if s := h.r.Stats(); s.RejoinTokens != 3 { + t.Fatalf("stats = %+v, want 3 tokens held", s) + } + // The oldest disconnected participant's token went first; the host's, + // still connected, stays. + if _, p := h.rejoin(t, toks[0], "198.51.100.7"); p.last(t, "error")["code"] != "bad-rejoin" { + t.Fatal("the oldest token outlived the bound") + } + if _, p := h.rejoin(t, toks[3], "198.51.100.7"); p.last(t, "joined") == nil { + t.Fatal("the newest token was dropped") + } + // A token is never echoed into the relay's state, only its digest. + if strings.Contains(h.r.debugState(), toks[3]) { + t.Fatal("the relay holds a rejoin token in the clear") + } +} + +// TestReplayIsOneQueuedItem: a newcomer's replay is handed to the transport +// as one item, whatever the log's length, so a log longer than any send queue +// never drops the newcomer; synced follows it. +func TestReplayIsOneQueuedItem(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.UpdatesPerSecond, l.UpdateBurst = 1<<20, 1<<20 }) + host, _, code := h.host(t, "tok-ada") + const n = 3000 + for i := 0; i < n; i++ { + host.Receive(context.Background(), update(byte(i), byte(i>>8))) + } + _, gp := h.join(t, code, "Gus", "198.51.100.7") + if gp.logs != 1 { + t.Fatalf("the replay was queued as %d items, want 1", gp.logs) + } + if got := len(gp.binaries()); got != n { + t.Fatalf("replayed %d entries, want %d", got, n) + } + if gp.last(t, "synced") == nil || gp.closed { + t.Fatalf("the newcomer was not synced, or was closed") + } +} + +func TestDefaultConnectionLimits(t *testing.T) { + l := defaultLimits() + if l.MaxConnections != 256 || l.MaxConnsPerAddr != 16 || l.AdmitWithin != 30*time.Second { + t.Fatalf("defaults = %d connections, %d per address, %v to enter; want 256, 16, 30s", l.MaxConnections, l.MaxConnsPerAddr, l.AdmitWithin) + } +} + +// TestConnectionsAreCappedRelayWideAndPerAddress: past either cap a new +// connection is refused and nothing else is touched; a connection that ends +// gives its place back. +func TestConnectionsAreCappedRelayWideAndPerAddress(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxConnections, l.MaxConnsPerAddr = 4, 2 }) + a1 := h.connect(t, &fakePeer{}, "203.0.113.5") + h.connect(t, &fakePeer{}, "203.0.113.5") + if _, err := h.r.Connect(&fakePeer{}, "203.0.113.5"); !errors.Is(err, ErrTooManyConnections) { + t.Fatalf("a third connection from one address: %v, want ErrTooManyConnections", err) + } + h.connect(t, &fakePeer{}, "2001:db8:1:1::1") + h.connect(t, &fakePeer{}, "2001:db8:1:2::1") + if _, err := h.r.Connect(&fakePeer{}, "198.51.100.7"); !errors.Is(err, ErrTooManyConnections) { + t.Fatalf("a fifth connection on the relay: %v, want ErrTooManyConnections", err) + } + if s := h.r.Stats(); s.Connections != 4 { + t.Fatalf("connections = %d, want 4", s.Connections) + } + a1.Leave() + a1.Leave() + h.connect(t, &fakePeer{}, "198.51.100.7") + if s := h.r.Stats(); s.Connections != 4 { + t.Fatalf("connections = %d after one left and one came, want 4", s.Connections) + } +} + +// TestClosingASessionGivesItsConnectionsBack: the host's close ends every +// connection in the session at once, and each one's place is free again once +// its transport has ended it: a close can wait behind a log replay, and the +// socket is held until then (final review A). +func TestClosingASessionGivesItsConnectionsBack(t *testing.T) { + h := newHarness(t, nil) + host, _, code := h.host(t, "tok-ada") + guest, _ := h.join(t, code, "Gus", "198.51.100.7") + send(t, host, msg{"type": "close"}) + if s := h.r.Stats(); s != (Stats{Connections: 2}) { + t.Fatalf("after the close the relay holds %+v, want only the two sockets", s) + } + host.Leave() + guest.Leave() + if s := h.r.Stats(); s != (Stats{}) { + t.Fatalf("after both transports left the relay holds %+v", s) + } +} + +// TestAConnectionThatEntersNoSessionExpires: a connection that has not +// hosted, joined or rejoined when the transport's grace period ends is closed +// (not as a refusal: the page may simply open another), and one that has is +// left alone. +func TestAConnectionThatEntersNoSessionExpires(t *testing.T) { + h := newHarness(t, nil) + idle := &fakePeer{} + c := h.connect(t, idle, "198.51.100.7") + send(t, c, msg{"type": "verify", "token": "tok-ada", "repo": "acme/brief"}) + c.Expire() + if !idle.closed || idle.policy { + t.Fatalf("an idle connection: closed=%v policy=%v, want a normal close", idle.closed, idle.policy) + } + if e := idle.last(t, "error"); e == nil || e["code"] != "join-timeout" { + t.Fatalf("error = %v, want join-timeout", e) + } + if s := h.r.Stats(); s.Connections != 1 { + t.Fatalf("connections = %d after the expiry, want the socket held until its transport ends it", s.Connections) + } + c.Leave() + if s := h.r.Stats(); s.Connections != 0 { + t.Fatalf("connections = %d after the transport left, want 0", s.Connections) + } + + host, hp, code := h.host(t, "tok-ada") + guest, gp := h.join(t, code, "Gus", "198.51.100.8") + host.Expire() + guest.Expire() + if hp.closed || gp.closed { + t.Fatal("a connection in a session expired") + } + if hp.entered != 1 || gp.entered != 1 { + t.Fatalf("entered = %d and %d, want each told once", hp.entered, gp.entered) + } +} + +// TestTheLogChargesEachEntryAndCapsTheirCount: an entry costs its bytes and +// a fixed overhead against the log limit, so a log of tiny updates cannot +// hold many times its limit, and the entries are capped in number too. +func TestTheLogChargesEachEntryAndCapsTheirCount(t *testing.T) { + if d := defaultLimits(); d.MaxLogEntries != 50000 || LogEntryOverhead != 64 { + t.Fatalf("defaults: %d entries, %d bytes of overhead; want 50000 and 64", d.MaxLogEntries, LogEntryOverhead) + } + h := newHarness(t, func(l *Limits) { l.MaxLogBytes = 150 }) + host, hp, _ := h.host(t, "tok-ada") + host.Receive(context.Background(), update(1)) + host.Receive(context.Background(), update(2)) + if e := hp.last(t, "error"); e != nil { + t.Fatalf("two one-byte updates: error %v", e) + } + host.Receive(context.Background(), update(3)) + if e := hp.last(t, "error"); e == nil || e["code"] != "log-full" { + t.Fatalf("a third one-byte update under 150 bytes: error = %v, want log-full", e) + } + + h = newHarness(t, func(l *Limits) { l.MaxLogEntries = 3 }) + host, hp, _ = h.host(t, "tok-ada") + for i := 0; i < 3; i++ { + host.Receive(context.Background(), update(byte(i))) + } + if e := hp.last(t, "error"); e != nil { + t.Fatalf("three updates: error %v", e) + } + host.Receive(context.Background(), update(9)) + if e := hp.last(t, "error"); e == nil || e["code"] != "log-full" { + t.Fatalf("a fourth entry past the cap: error = %v, want log-full", e) + } +} + +// TestUpdatesAreRateLimitedPerConnection: a member's updates past the bucket +// are dropped with rate-limited, neither logged nor forwarded, and the +// connection stays open. +func TestUpdatesAreRateLimitedPerConnection(t *testing.T) { + l := defaultLimits() + if l.UpdatesPerSecond != 60 || l.UpdateBurst != 200 { + t.Fatalf("update default = %d/s burst %d, want 60/s burst 200", l.UpdatesPerSecond, l.UpdateBurst) + } + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + _, gp := h.join(t, code, "Gus", "198.51.100.7") + gp.reset() + for i := 0; i < l.UpdateBurst; i++ { + host.Receive(context.Background(), update(byte(i))) + } + if e := hp.last(t, "error"); e != nil { + t.Fatalf("within the burst: error %v", e) + } + host.Receive(context.Background(), update(1)) + if !rateLimited(t, hp) { + t.Fatalf("past the burst: want rate-limited with the connection open, got %v", hp.last(t, "error")) + } + if n := len(gp.binaries()); n != l.UpdateBurst { + t.Fatalf("forwarded %d updates, want %d", n, l.UpdateBurst) + } + if s := h.r.Stats(); s.LogBytes != l.UpdateBurst*(LogEntryOverhead+2) { + t.Fatalf("logged %d bytes, want the burst alone", s.LogBytes) + } + h.clock.advance(time.Second) + hp.reset() + for i := 0; i < l.UpdatesPerSecond; i++ { + host.Receive(context.Background(), update(byte(i))) + } + if e := hp.last(t, "error"); e != nil { + t.Fatalf("a second later, %d more: error %v", l.UpdatesPerSecond, e) + } +} + +// TestUpdatesAreAttributedToTheirSender: the relay sends every member's +// update on as tag 2 with the sender's participant number, live and in the +// replay; the roster names each author's login, also after they left; and a +// participant cannot send tag 2 itself. +func TestUpdatesAreAttributedToTheirSender(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + bea, bp := h.join(t, code, "", "198.51.100.8") + send(t, bea, msg{"type": "verify", "token": "tok-bea"}) + beaTok := rejoinToken(t, bp, "joined") + guest, gp := h.join(t, code, "Gus", "198.51.100.7") + gp.reset() + + host.Receive(context.Background(), update(1)) + bea.Receive(context.Background(), update(2)) + got := gp.binaries() + if len(got) != 2 || !bytes.Equal(got[0], attributed(host.id, 1)) || !bytes.Equal(got[1], attributed(bea.id, 2)) { + t.Fatalf("the guest received %v, want each update attributed to its sender", got) + } + authors := func(p *fakePeer) map[string]any { + r := p.last(t, "roster") + a, _ := r["authors"].(map[string]any) + return a + } + num := func(id string) string { return strings.TrimPrefix(id, "p") } + if a := authors(gp); a[num(host.id)] != "ada" || a[num(bea.id)] != "bea" || len(a) != 2 { + t.Fatalf("roster authors = %v, want ada and bea by participant number", a) + } + + // A participant cannot claim another's attribution. + forged := Frame{Binary: true, Data: attributed(host.id, 9)} + bea.Receive(context.Background(), forged) + if e := bp.last(t, "error"); e == nil || e["code"] != "unknown-tag" { + t.Fatalf("a client's tag 2: error = %v, want unknown-tag", e) + } + + // Bea leaves; a newcomer's replay still names her, and so does the roster. + beaID := bea.id + bea.Leave() + _, np := h.join(t, code, "Nia", "198.51.100.9") + replay := np.binaries() + if len(replay) != 2 || !bytes.Equal(replay[1], attributed(beaID, 2)) { + t.Fatalf("the newcomer's replay = %v", replay) + } + if a := authors(np); a[num(beaID)] != "bea" { + t.Fatalf("roster authors after bea left = %v", a) + } + + // Rejoining keeps the participant number, so the attribution holds. + back, _ := h.rejoin(t, beaTok, "198.51.100.8") + if back.id != beaID { + t.Fatalf("bea rejoined as %s, want %s", back.id, beaID) + } + gp.reset() + back.Receive(context.Background(), update(3)) + if got := gp.binaries(); len(got) != 1 || !bytes.Equal(got[0], attributed(beaID, 3)) { + t.Fatalf("after the rejoin the guest received %v", got) + } + _ = guest + _ = hp +} + +// TestAwarenessIsAttributedToItsSender: the relay sends every awareness +// message on as tag 3 with the sender's participant number, exactly as it +// sends an update on as tag 2, from a guest as from a member; it logs none of +// them; and a participant cannot send tag 3 itself. +func TestAwarenessIsAttributedToItsSender(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + guest, gp := h.join(t, code, "Gus", "198.51.100.7") + gusTok := rejoinToken(t, gp, "joined") + hp.reset() + gp.reset() + + guest.Receive(context.Background(), Frame{Binary: true, Data: []byte{TagAwareness, 4, 5}}) + host.Receive(context.Background(), Frame{Binary: true, Data: []byte{TagAwareness, 6}}) + if got := hp.binaries(); len(got) != 1 || !bytes.Equal(got[0], attributedAwareness(guest.id, 4, 5)) { + t.Fatalf("the host received %v, want the guest's awareness attributed to %s", got, guest.id) + } + if got := gp.binaries(); len(got) != 1 || !bytes.Equal(got[0], attributedAwareness(host.id, 6)) { + t.Fatalf("the guest received %v, want the host's awareness attributed to %s", got, host.id) + } + if s := h.r.Stats(); s.LogBytes != 0 { + t.Fatalf("awareness was logged: %+v", s) + } + + // A participant cannot claim another's attribution. + hp.reset() + guest.Receive(context.Background(), Frame{Binary: true, Data: attributedAwareness(host.id, 9)}) + if e := gp.last(t, "error"); e == nil || e["code"] != "unknown-tag" { + t.Fatalf("a client's tag 3: error = %v, want unknown-tag", e) + } + if got := hp.binaries(); len(got) != 0 { + t.Fatalf("a client's tag 3 was forwarded: %v", got) + } + + // Rejoining keeps the participant number, so the attribution holds. + gusID := guest.id + guest.Leave() + back, _ := h.rejoin(t, gusTok, "198.51.100.7") + hp.reset() + back.Receive(context.Background(), Frame{Binary: true, Data: []byte{TagAwareness, 7}}) + if got := hp.binaries(); len(got) != 1 || !bytes.Equal(got[0], attributedAwareness(gusID, 7)) { + t.Fatalf("after the rejoin the host received %v", got) + } +} + +// TestRosterNamesTheHostsLogin: the roster names the login that opened the +// session, also while the host is not connected, so a page can tell the +// host's writes from anyone else's. +func TestRosterNamesTheHostsLogin(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + if r := hp.last(t, "roster"); r == nil || r["host"] != "ada" { + t.Fatalf("the host's roster = %v, want host ada", r) + } + bea, bp := h.join(t, code, "", "198.51.100.8") + send(t, bea, msg{"type": "verify", "token": "tok-bea"}) + host.Leave() + if r := bp.last(t, "roster"); r == nil || r["host"] != "ada" { + t.Fatalf("after the host left, the roster = %v, want host ada", r) + } + _, np := h.join(t, code, "Nia", "198.51.100.9") + if r := np.last(t, "roster"); r == nil || r["host"] != "ada" { + t.Fatalf("a newcomer's roster while the host is away = %v, want host ada", r) + } +} + +// TestAuthorsAreBoundedPerSession: a session names at most MaxCredits +// authors; past it a new author's update is refused and not logged. +func TestAuthorsAreBoundedPerSession(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.MaxCredits = 1 }) + host, _, code := h.host(t, "tok-ada") + host.Receive(context.Background(), update(1)) + bea, bp := h.join(t, code, "", "198.51.100.8") + send(t, bea, msg{"type": "verify", "token": "tok-bea"}) + bea.Receive(context.Background(), update(2)) + if e := bp.last(t, "error"); e == nil || e["code"] != "too-many-authors" { + t.Fatalf("a second author past the bound: error = %v, want too-many-authors", e) + } + if s := h.r.Stats(); s.LogBytes != LogEntryOverhead+2 { + t.Fatalf("log bytes = %d, want the first update alone", s.LogBytes) + } +} + +// TestWrongCodesAreBudgetedRelayWide: past the relay-wide budget of wrong +// codes, joining by code pauses for everybody until the window turns. During +// the pause every code gets the same answer, right or wrong, and counts for +// its own address but not towards the pause. A rejoin token and a verified +// member are not held up. +func TestWrongCodesAreBudgetedRelayWide(t *testing.T) { + if d := defaultLimits(); d.RelayFailedJoins != 600 || d.FailedJoinWindow != 10*time.Minute { + t.Fatalf("defaults: %d wrong codes in %v, want 600 in 10m", d.RelayFailedJoins, d.FailedJoinWindow) + } + h := newHarness(t, func(l *Limits) { + l.RelayFailedJoins = 5 + l.MaxFailedJoins = 3 + }) + _, _, code := h.host(t, "tok-ada") + _, gp := h.join(t, code, "Gus", "198.51.100.200") + gusTok := rejoinToken(t, gp, "joined") + for i := 0; i < 5; i++ { + _, p := h.join(t, "ZZZZZZ", "Mal", "203.0.113."+strconv.Itoa(i+1)) + if e := p.last(t, "error"); e == nil || e["code"] != "bad-code" { + t.Fatalf("wrong code %d: %v, want bad-code", i, e) + } + } + // The pause: a right code and a wrong one get the same answer. + for _, c := range []string{code, "ZZZZZZ"} { + _, p := h.join(t, c, "Ann", "198.51.100.1") + if e := p.last(t, "error"); e == nil || e["code"] != "paused" || p.last(t, "joined") != nil { + t.Fatalf("joining with %s during the pause: %v, want paused", c, p.texts(t)) + } + } + // Each try during the pause counts for its own address. + _, p := h.join(t, code, "Ann", "198.51.100.1") + if !p.closed { + t.Fatal("an address past its own limit during the pause stayed open") + } + // A rejoin token and a verified member carry on. + if _, rp := h.rejoin(t, gusTok, "198.51.100.200"); rp.last(t, "joined") == nil { + t.Fatalf("a rejoin during the pause: %v", rp.texts(t)) + } + mp := &fakePeer{} + m := h.connect(t, mp, "198.51.100.50") + send(t, m, msg{"type": "verify", "token": "tok-bea", "repo": "acme/brief"}) + send(t, m, msg{"type": "join", "code": code}) + if mp.last(t, "joined") == nil { + t.Fatalf("a verified member during the pause: %v", mp.texts(t)) + } + // The tries during the pause did not extend it: once the window turns, + // joining by code works again. + h.clock.advance(defaultLimits().FailedJoinWindow) + if _, p := h.join(t, code, "Bo", "198.51.100.2"); p.last(t, "joined") == nil { + t.Fatalf("after the pause: %v", p.texts(t)) + } +} + +// TestForgedRejoinTokensDoNotPauseJoining: wrong rejoin tokens count for +// their address alone, never towards the relay-wide budget. +func TestForgedRejoinTokensDoNotPauseJoining(t *testing.T) { + h := newHarness(t, func(l *Limits) { l.RelayFailedJoins = 2 }) + _, _, code := h.host(t, "tok-ada") + for i := 0; i < 4; i++ { + h.rejoin(t, "forged-token-"+strconv.Itoa(i), "203.0.113."+strconv.Itoa(i+1)) + } + if _, p := h.join(t, code, "Gus", "198.51.100.7"); p.last(t, "joined") == nil { + t.Fatalf("joining after forged rejoin tokens: %v", p.texts(t)) + } +} + +// TestOnlyServedRepositoriesAreVerifiedOrHosted: a relay serving a list of +// repositories refuses to verify against, or host, any other, before asking +// GitHub anything; an owner on the list serves every repository it holds. +func TestOnlyServedRepositoriesAreVerifiedOrHosted(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)} + ver := &fakeVerifier{ + logins: map[string]string{"tok-ada": "ada", "tok-mal": "mal"}, + push: map[string]map[string]bool{ + "ada": {"acme/brief": true, "team/notes": true}, + "mal": {"mal/own": true}, + }, + } + r := New(Options{Verifier: ver, Now: clock.Now, Repos: []string{"Acme/Brief"}, Owners: []string{"TEAM"}}) + if r.Serves("mal/own") || !r.Serves("acme/brief") || !r.Serves("team/notes") || r.Serves("acme/other") { + t.Fatal("Serves does not follow the lists") + } + + mp := &fakePeer{} + mal, _ := r.Connect(mp, "203.0.113.5") + send(t, mal, msg{"type": "verify", "token": "tok-mal", "repo": "mal/own"}) + if e := mp.last(t, "error"); e == nil || e["code"] != "repo-not-served" { + t.Fatalf("verify against an unserved repository: %v, want repo-not-served", e) + } + if ver.calls != 0 { + t.Fatalf("GitHub was asked %d times about an unserved repository", ver.calls) + } + send(t, mal, msg{"type": "host", "repo": "mal/own"}) + if e := mp.last(t, "error"); e == nil || e["code"] != "repo-not-served" { + t.Fatalf("host an unserved repository: %v, want repo-not-served", e) + } + if s := r.Stats(); s.Sessions != 0 { + t.Fatalf("sessions = %d, want none", s.Sessions) + } + + for _, repo := range []string{"acme/brief", "Team/Notes"} { + p := &fakePeer{} + c, _ := r.Connect(p, "192.0.2.1") + send(t, c, msg{"type": "verify", "token": "tok-ada", "repo": repo}) + send(t, c, msg{"type": "host", "repo": repo}) + if p.last(t, "hosted") == nil { + t.Fatalf("hosting %s: %v", repo, p.texts(t)) + } + } + + // No list at all serves every repository. + open := New(Options{Verifier: ver, Now: clock.Now}) + if !open.Serves("mal/own") { + t.Fatal("a relay with no list does not serve every repository") + } +} + +func TestValidOwner(t *testing.T) { + for _, s := range []string{"acme", "a", "Acme-Co", "a1"} { + if !ValidOwner(s) { + t.Errorf("%q refused", s) + } + } + for _, s := range []string{"", "-acme", "acme/brief", "ac me", "a_b", strings.Repeat("a", 40)} { + if ValidOwner(s) { + t.Errorf("%q accepted", s) + } + } +} + +// TestAwarenessFramesAreCappedInSize: an awareness frame is a cursor and a +// name, a few hundred bytes, so one over MaxAwarenessBytes is refused and +// never forwarded (security review 2, item 1). +func TestAwarenessFramesAreCappedInSize(t *testing.T) { + if d := defaultLimits(); d.MaxAwarenessBytes != 8<<10 { + t.Fatalf("MaxAwarenessBytes = %d, want 8 KiB", d.MaxAwarenessBytes) + } + h := newHarness(t, nil) + _, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + hp.reset() + g.Receive(context.Background(), Frame{Binary: true, Data: append([]byte{TagAwareness}, make([]byte, 8<<10)...)}) + if e := gp.last(t, "error"); e == nil || e["code"] != "too-big" { + t.Fatalf("an awareness frame over 8 KiB: %v, want too-big", e) + } + if n := len(hp.binaries()); n != 0 { + t.Fatalf("an oversized awareness frame was forwarded %d times", n) + } + g.Receive(context.Background(), Frame{Binary: true, Data: append([]byte{TagAwareness}, make([]byte, 8<<10-1)...)}) + if n := len(hp.binaries()); n != 1 { + t.Fatalf("an awareness frame of exactly 8 KiB was forwarded %d times, want once", n) + } +} + +// TestAwarenessBytesAreRatedPerRoom: every participant's awareness together +// is held to a byte rate per session, so many connections each inside their +// own frame rate cannot flood the room's queues. +func TestAwarenessBytesAreRatedPerRoom(t *testing.T) { + d := defaultLimits() + if d.AwarenessBytesPerSecond != 128<<10 || d.AwarenessBytesBurst != 512<<10 { + t.Fatalf("room awareness defaults = %d/s burst %d, want 128 KiB/s burst 512 KiB", d.AwarenessBytesPerSecond, d.AwarenessBytesBurst) + } + h := newHarness(t, func(l *Limits) { + l.AwarenessPerSecond, l.AwarenessBurst = 1<<20, 1<<20 + l.AwarenessBytesPerSecond, l.AwarenessBytesBurst = 4096, 4096*4 + }) + host, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + frame := Frame{Binary: true, Data: append([]byte{TagAwareness}, make([]byte, 4095)...)} + hp.reset() + for i := 0; i < 4; i++ { + g.Receive(context.Background(), frame) + } + if n := len(hp.binaries()); n != 4 { + t.Fatalf("the room's burst forwarded %d frames, want 4", n) + } + g.Receive(context.Background(), frame) + if !rateLimited(t, gp) || len(hp.binaries()) != 4 { + t.Fatal("a frame past the room's byte burst was not refused") + } + // The budget is the room's: the host is held to it too. + host.Receive(context.Background(), frame) + if !rateLimited(t, hp) { + t.Fatal("the host's awareness was not held to the room's byte rate") + } + h.clock.advance(time.Second) + gp.reset() + hp.reset() + g.Receive(context.Background(), frame) + if n := len(hp.binaries()); n != 1 { + t.Fatalf("a second later %d frames were forwarded, want 1", n) + } +} + +// TestSuggestionsAreBoundedInSizeCountAndRoomBytes: a guest's suggestions +// end up in the session's log (a member's page records each), so each is +// capped in bytes, each connection in count, and each session in total bytes +// (security review 2, item 2). +func TestSuggestionsAreBoundedInSizeCountAndRoomBytes(t *testing.T) { + d := defaultLimits() + if d.MaxSuggestBytes != 70<<10 || d.MaxSuggestsPerSeat != 100 || d.MaxRoomSuggestBytes != 1<<20 { + t.Fatalf("suggestion defaults = %d bytes, %d a connection, %d a room; want 70 KiB, 100, 1 MiB", + d.MaxSuggestBytes, d.MaxSuggestsPerSeat, d.MaxRoomSuggestBytes) + } + delivered := func(p *fakePeer) int { + n := 0 + for _, m := range p.texts(t) { + if m["type"] == "suggest" { + n++ + } + } + return n + } + t.Run("size", func(t *testing.T) { + h := newHarness(t, nil) + _, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + big := strings.Repeat("x", 70<<10) + send(t, g, msg{"type": "suggest", "body": msg{"replacement": big}}) + if e := gp.last(t, "error"); e == nil || e["code"] != "suggestion-too-big" || delivered(hp) != 0 { + t.Fatalf("a suggestion over 70 KiB: %v, delivered %d", e, delivered(hp)) + } + send(t, g, msg{"type": "suggest", "body": msg{"replacement": big[:60<<10]}}) + if delivered(hp) != 1 { + t.Fatal("a suggestion under the cap was not delivered") + } + }) + t.Run("count", func(t *testing.T) { + h := newHarness(t, func(l *Limits) { + l.SuggestPerSecond, l.SuggestBurst = 1<<20, 1<<20 + l.MaxSuggestsPerSeat = 3 + }) + _, hp, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + for i := 0; i < 4; i++ { + send(t, g, msg{"type": "suggest", "body": msg{"replacement": "x"}}) + } + if e := gp.last(t, "error"); e == nil || e["code"] != "too-many-suggestions" || delivered(hp) != 3 { + t.Fatalf("the fourth suggestion: %v, delivered %d, want too-many-suggestions and 3", e, delivered(hp)) + } + }) + t.Run("room", func(t *testing.T) { + h := newHarness(t, func(l *Limits) { + l.SuggestPerSecond, l.SuggestBurst = 1<<20, 1<<20 + l.MaxRoomSuggestBytes = 3000 + }) + _, hp, code := h.host(t, "tok-ada") + g1, _ := h.join(t, code, "Gus", "198.51.100.7") + g2, gp2 := h.join(t, code, "Ida", "198.51.100.8") + body := msg{"replacement": strings.Repeat("y", 1400)} + send(t, g1, msg{"type": "suggest", "body": body}) + send(t, g2, msg{"type": "suggest", "body": body}) + send(t, g2, msg{"type": "suggest", "body": body}) + if e := gp2.last(t, "error"); e == nil || e["code"] != "suggestions-full" || delivered(hp) != 2 { + t.Fatalf("past the room's suggestion bytes: %v, delivered %d, want suggestions-full and 2", e, delivered(hp)) + } + }) +} + +// TestRefusedMessagesAreBudgetedPerConnection: every control message, and +// every binary frame the relay refuses, spends a token of one bucket per +// connection; past it a message is dropped unanswered without taking the +// relay's lock, and a connection refused MaxRefusals times is closed +// (security review 2, item 7). +func TestRefusedMessagesAreBudgetedPerConnection(t *testing.T) { + d := defaultLimits() + if d.MessagesPerSecond != 50 || d.MessageBurst != 100 || d.MaxRefusals != 1000 { + t.Fatalf("message defaults = %d/s burst %d, close after %d; want 50/s, 100, 1000", d.MessagesPerSecond, d.MessageBurst, d.MaxRefusals) + } + h := newHarness(t, nil) + _, _, code := h.host(t, "tok-ada") + g, gp := h.join(t, code, "Gus", "198.51.100.7") + gp.reset() + errs := func() int { + n := 0 + for _, m := range gp.texts(t) { + if m["type"] == "error" { + n++ + } + } + return n + } + // The join spent one token of the burst. + for i := 0; i < d.MessageBurst+20; i++ { + send(t, g, msg{"type": "nonsense"}) + } + if n := errs(); n != d.MessageBurst-1 { + t.Fatalf("%d refusals answered, want the burst's %d", n, d.MessageBurst-1) + } + // Past the budget the relay's lock is not taken. + h.r.mu.Lock() + done := make(chan struct{}) + go func() { + send(t, g, msg{"type": "nonsense"}) + g.Receive(context.Background(), Frame{Binary: true, Data: []byte{9}}) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + h.r.mu.Unlock() + t.Fatal("a message past the budget waited for the relay's lock") + } + h.r.mu.Unlock() + // A member's accepted updates spend nothing of it. + h.clock.advance(time.Second) + gp.reset() + send(t, g, msg{"type": "nonsense"}) + if errs() != 1 { + t.Fatal("a second later the refill was not spent on the next message") + } + // Sustained abuse closes the connection. + for i := 0; i < d.MaxRefusals && !gp.closed; i++ { + send(t, g, msg{"type": "nonsense"}) + } + if !gp.closed || !gp.policy { + t.Fatalf("after %d refusals the connection is open (closed=%v)", d.MaxRefusals, gp.closed) + } +} + +// TestAcceptedFramesSpendNoMessageBudget: a member typing fast is held only +// by the update rate, never by the message budget. +func TestAcceptedFramesSpendNoMessageBudget(t *testing.T) { + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + h.join(t, code, "Gus", "198.51.100.7") + hp.reset() + for i := 0; i < defaultLimits().UpdateBurst; i++ { + host.Receive(context.Background(), update(byte(i))) + } + if e := hp.last(t, "error"); e != nil { + t.Fatalf("an update inside the update burst was refused: %v", e) + } +} + +// TestSessionsExpireAfterTheirLifetime: a session ends MaxSessionLife after +// it was opened, however busy, like a host's close, with closed +// {reason: "expired"}; so a rejoin token, which restores a member's role +// after GitHub has revoked it, lasts at most that long (security review 2, +// item 8). +func TestSessionsExpireAfterTheirLifetime(t *testing.T) { + if d := defaultLimits(); d.MaxSessionLife != 12*time.Hour { + t.Fatalf("MaxSessionLife = %v, want 12h", d.MaxSessionLife) + } + h := newHarness(t, nil) + host, hp, code := h.host(t, "tok-ada") + tok := rejoinToken(t, hp, "hosted") + _, gp := h.join(t, code, "Gus", "198.51.100.7") + for i := 0; i < 11; i++ { + h.clock.advance(time.Hour) + host.Receive(context.Background(), update(byte(i))) + h.r.Sweep() + } + if s := h.r.Stats(); s.Sessions != 1 { + t.Fatal("a session was ended before its lifetime") + } + h.clock.advance(time.Hour) + h.r.Sweep() + for _, p := range []*fakePeer{hp, gp} { + if c := p.last(t, "closed"); c == nil || c["reason"] != "expired" || !p.closed { + t.Fatalf("a participant of an expired session: %v, closed=%v", p.last(t, "closed"), p.closed) + } + } + if s := h.r.Stats(); s != (Stats{Connections: 2}) { + t.Fatalf("after the session expired, the relay still holds %+v", s) + } + if _, p := h.rejoin(t, tok, "192.0.2.1"); p.last(t, "error")["code"] != "bad-rejoin" { + t.Fatal("an expired session's rejoin token still works") + } + + // Between sweeps, an expired session admits nobody. + h2 := newHarness(t, nil) + _, hp2, code2 := h2.host(t, "tok-ada") + tok2 := rejoinToken(t, hp2, "hosted") + h2.clock.advance(12 * time.Hour) + if _, p := h2.join(t, code2, "Late", "198.51.100.9"); p.last(t, "joined") != nil { + t.Fatal("an expired session admitted a guest before the sweep") + } + if c := hp2.last(t, "closed"); c == nil || c["reason"] != "expired" { + t.Fatalf("the host of a session found expired was not told: %v", hp2.texts(t)) + } + if _, p := h2.rejoin(t, tok2, "192.0.2.1"); p.last(t, "joined") != nil { + t.Fatal("an expired session admitted a rejoin") + } +} + +// TestTokenExchangesAreLimitedPerAddress: the sign-in exchange is counted per +// address, keyed as wrong codes are, at most MaxExchangesPerAddr in +// ExchangeWindow (security review 2, item 10). +func TestTokenExchangesAreLimitedPerAddress(t *testing.T) { + if d := defaultLimits(); d.MaxExchangesPerAddr != 10 || d.ExchangeWindow != 10*time.Minute { + t.Fatalf("exchange defaults = %d in %v, want 10 in 10m", d.MaxExchangesPerAddr, d.ExchangeWindow) + } + h := newHarness(t, nil) + for i := 0; i < 10; i++ { + if !h.r.AllowExchange("2001:db8:1:2::" + strconv.Itoa(i+1)) { + t.Fatalf("exchange %d refused", i+1) + } + } + // The same /48 is one address. + if h.r.AllowExchange("2001:db8:1:ffff::1") { + t.Fatal("an eleventh exchange from the same /48 was allowed") + } + if !h.r.AllowExchange("203.0.113.9") { + t.Fatal("another address was held up") + } + h.clock.advance(10 * time.Minute) + if !h.r.AllowExchange("2001:db8:1:2::1") { + t.Fatal("after the window the address is still refused") + } +} diff --git a/internal/core/session/setup.go b/internal/core/session/setup.go new file mode 100644 index 000000000..a268a526c --- /dev/null +++ b/internal/core/session/setup.go @@ -0,0 +1,216 @@ +package session + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path" + "regexp" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// Setting up live sessions at repository setup (spc-2610141709430146 step +// 6, adr-2610161400542916): the team registers its own GitHub App, once, +// through GitHub's App manifest flow +// (https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest). +// A form posts a pre-filled manifest to GitHub, the person clicks Create, +// GitHub redirects back with a code, and the code converts, within the hour, +// into the app's credentials. abcd keeps only the public client id, in +// .abcd/relay/github-app.json; the client secret, the webhook secret and the +// private key are never kept, written or shown. + +// Where sessions may run, as repository setup records it. +const ( + WhereLocal = "local" + WhereCloud = "cloud" + WhereBoth = "both" +) + +// ValidWhere reports whether w is where sessions may run. +func ValidWhere(w string) bool { return w == WhereLocal || w == WhereCloud || w == WhereBoth } + +// AllowsLocal reports whether w allows a session on the host's computer. +func AllowsLocal(w string) bool { return w == WhereLocal || w == WhereBoth } + +// AllowsCloud reports whether w allows a session on the team's relay. +func AllowsCloud(w string) bool { return w == WhereCloud || w == WhereBoth } + +// maxAppName is the longest name GitHub gives an app. +const maxAppName = 34 + +// AppManifest is the GitHub App manifest repository setup posts. It asks for +// no webhook, so it carries no hook_attributes and the app receives no event. +type AppManifest struct { + Name string `json:"name"` + URL string `json:"url"` + RedirectURL string `json:"redirect_url"` + CallbackURLs []string `json:"callback_urls,omitempty"` + Description string `json:"description"` + Public bool `json:"public"` + DefaultPermissions map[string]string `json:"default_permissions"` + DefaultEvents []string `json:"default_events"` +} + +// NewAppManifest is the manifest for the repository owner/name's app, where +// sessions may run where, returning to redirectURL once GitHub has created +// it. A cloud relay signs in through GitHub's web flow, which returns to the +// relay's own callback (RelayCallbackURL), so the manifest names it when the +// relay's address, relayOrigin, is already known; otherwise setup names it to +// add once the relay is deployed. A local session signs in through the device +// flow, which needs none. The permissions are the ones the page's work +// needs, and no more: Contents and Pull requests read and write, Metadata +// read. +func NewAppManifest(repo, where, redirectURL, relayOrigin string) (AppManifest, error) { + if !ValidRepo(repo) { + return AppManifest{}, fmt.Errorf("%q is not an owner/name repository", repo) + } + if !ValidWhere(where) { + return AppManifest{}, fmt.Errorf("%q is not where sessions may run: local, cloud or both", where) + } + callback := "" + if relayOrigin != "" { + if callback = RelayCallbackURL(relayOrigin); callback == "" { + return AppManifest{}, fmt.Errorf("%q is not the relay's https origin", relayOrigin) + } + } + owner, name, _ := strings.Cut(repo, "/") + m := AppManifest{ + Name: appName(owner, name), + URL: "https://github.com/" + repo, + RedirectURL: redirectURL, + Description: "Signs the team in to live brief sessions for " + repo + ". Registered by abcd's repository setup.", + Public: false, + DefaultPermissions: map[string]string{ + "contents": "write", + "pull_requests": "write", + "metadata": "read", + }, + DefaultEvents: []string{}, + } + if AllowsCloud(where) && callback != "" { + m.CallbackURLs = []string{callback} + } + return m, nil +} + +// RelayCallbackPath is the path on the team's relay that GitHub's web +// sign-in returns to. +const RelayCallbackPath = "/auth/callback" + +// RelayCallbackURL is the callback URL the cloud's web sign-in returns to: +// the team relay's own origin and RelayCallbackPath. GitHub sends a sign-in +// code only to a callback URL the app registers, so registering the relay's +// own address binds every code to that relay. relayOrigin is read as +// NormalizeRelayOrigin reads it; one it refuses names no callback (""). +func RelayCallbackURL(relayOrigin string) string { + o, err := NormalizeRelayOrigin(relayOrigin) + if err != nil { + return "" + } + return o + RelayCallbackPath +} + +// FormerPageCallbackURL is the callback URL an earlier repository setup had +// the team's app list for repo: the session page's own address on +// DefaultPageOrigin. The sign-in now returns to the relay's own callback +// (RelayCallbackURL), so setup tells an app recorded before to remove it if +// it is still listed; nothing returns there any more. +func FormerPageCallbackURL(repo string) string { + return DefaultPageOrigin + "/" + repo + "/brief/" +} + +// appName is the app's name, within GitHub's length: the owner and the +// repository, then "brief". GitHub's own page lets the person change it +// before creating the app, should the name be taken. +func appName(owner, name string) string { + n := owner + " " + name + " brief" + if len(n) > maxAppName { + n = strings.TrimRight(n[:maxAppName], " -_.") + } + return n +} + +// ManifestFormURL is the page the manifest form posts to: an organisation's +// new-app page, or the person's own, carrying state, which GitHub returns. +func ManifestFormURL(owner string, org bool, state string) string { + base := "https://github.com/settings/apps/new" + if org { + base = "https://github.com/organizations/" + url.PathEscape(owner) + "/settings/apps/new" + } + return base + "?state=" + url.QueryEscape(state) +} + +// CreatedApp is what repository setup keeps of the manifest conversion's +// answer: the public client id and where the app's pages are. It has no +// field a secret could land in. +type CreatedApp struct { + ClientID string `json:"client_id"` + Slug string `json:"slug"` + OwnerLogin string `json:"owner"` + OwnerType string `json:"owner_type"` +} + +// slugRe is an app's slug as it travels into GitHub's addresses. +var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,99}$`) + +// ParseConversion reads the manifest conversion's answer +// (POST /app-manifests/{code}/conversions), keeping only CreatedApp's +// fields: the secrets in the answer are never decoded into anything. +func ParseConversion(body []byte) (CreatedApp, error) { + var raw struct { + ClientID string `json:"client_id"` + Slug string `json:"slug"` + Owner struct { + Login string `json:"login"` + Type string `json:"type"` + } `json:"owner"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return CreatedApp{}, errors.New("GitHub's answer is not the app it created") + } + app := CreatedApp{ClientID: raw.ClientID, Slug: raw.Slug, OwnerLogin: raw.Owner.Login, OwnerType: raw.Owner.Type} + if !ValidClientID(app.ClientID) || !slugRe.MatchString(app.Slug) || !ValidOwner(app.OwnerLogin) { + return CreatedApp{}, errors.New("GitHub's answer names no app abcd can use") + } + return app, nil +} + +// SettingsURL is the app's settings page, where its device flow is switched +// on and its client secret generated. +func (a CreatedApp) SettingsURL() string { + if a.OwnerType == "Organization" { + return "https://github.com/organizations/" + a.OwnerLogin + "/settings/apps/" + a.Slug + } + return "https://github.com/settings/apps/" + a.Slug +} + +// InstallURL is the page that installs the app on a repository. +func (a CreatedApp) InstallURL() string { + return "https://github.com/apps/" + a.Slug + "/installations/new" +} + +// WriteAppFile writes AppFile, naming clientID and nothing else, under +// BlueprintDir through the checkout's root, so a symlink cannot lead the +// write elsewhere. +func WriteAppFile(root, clientID string) error { + if !ValidClientID(clientID) { + return fmt.Errorf("%q is not a GitHub App's client id", clientID) + } + body, err := json.MarshalIndent(map[string]string{"client_id": clientID}, "", " ") + if err != nil { + return err + } + if err := fsutil.EnsureRealDirAll(root, BlueprintDir, 0o755); err != nil { + return fmt.Errorf("creating %s/: %w", BlueprintDir, err) + } + r, err := os.OpenRoot(root) + if err != nil { + return err + } + defer r.Close() + return fsutil.WriteFileAtomicInRoot(r, path.Join(BlueprintDir, AppFile), append(body, '\n'), 0o644) +} diff --git a/internal/core/session/setup_test.go b/internal/core/session/setup_test.go new file mode 100644 index 000000000..368194018 --- /dev/null +++ b/internal/core/session/setup_test.go @@ -0,0 +1,174 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Setting up live sessions at repository setup (spc-2610141709430146 step +// 6): the team's own GitHub App, from a manifest. + +func TestAppManifestPrefillsTheTeamsApp(t *testing.T) { + // The cloud's web sign-in returns to the relay's own callback, so the + // manifest names it only once the relay's address is known; a local + // session signs in by the device flow and needs none. + for _, c := range []struct { + where, relay string + callbacks []string + }{ + {"local", "", nil}, + {"local", "https://brief-relay.onrender.com", nil}, + {"cloud", "", nil}, + {"cloud", "https://brief-relay.onrender.com", []string{"https://brief-relay.onrender.com/auth/callback"}}, + {"both", "https://brief-relay.onrender.com", []string{"https://brief-relay.onrender.com/auth/callback"}}, + } { + m, err := NewAppManifest("acme/brief", c.where, "http://127.0.0.1:4321/created", c.relay) + if err != nil { + t.Fatalf("%s: %v", c.where, err) + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if got["url"] != "https://github.com/acme/brief" || got["redirect_url"] != "http://127.0.0.1:4321/created" || got["public"] != false { + t.Errorf("%s: manifest = %s", c.where, raw) + } + perms, _ := got["default_permissions"].(map[string]any) + if len(perms) != 3 || perms["contents"] != "write" || perms["pull_requests"] != "write" || perms["metadata"] != "read" { + t.Errorf("%s: permissions = %v", c.where, perms) + } + if _, hook := got["hook_attributes"]; hook { + t.Errorf("%s: the manifest asks for a webhook: %s", c.where, raw) + } + cbs, _ := got["callback_urls"].([]any) + if len(cbs) != len(c.callbacks) || (len(cbs) == 1 && cbs[0] != c.callbacks[0]) { + t.Errorf("%s: callback urls = %v, want %v", c.where, cbs, c.callbacks) + } + name, _ := got["name"].(string) + if name == "" || len(name) > 34 { + t.Errorf("%s: name %q is not a GitHub App name", c.where, name) + } + } + if _, err := NewAppManifest("acme/brief", "anywhere", "http://127.0.0.1:1/created", ""); err == nil { + t.Error("an unknown where was accepted") + } + if _, err := NewAppManifest("acme", "local", "http://127.0.0.1:1/created", ""); err == nil { + t.Error("a repository without a name was accepted") + } + m, _ := NewAppManifest("a-very-long-organisation-name/an-even-longer-repository-name", "local", "http://127.0.0.1:1/created", "") + if len(m.Name) > 34 { + t.Errorf("name %q is longer than GitHub allows", m.Name) + } +} + +// The callback is the relay's own: its origin and /auth/callback, the path +// the relay answers GitHub's return on. A relay address that is not an https +// origin names none. +func TestTheCallbackIsTheRelays(t *testing.T) { + for in, want := range map[string]string{ + "https://brief-relay.onrender.com": "https://brief-relay.onrender.com/auth/callback", + "https://Brief-Relay.onrender.com/": "https://brief-relay.onrender.com/auth/callback", + "https://relay.example.org:8443": "https://relay.example.org:8443/auth/callback", + "https://brief-relay.onrender.com:443": "https://brief-relay.onrender.com/auth/callback", + "http://brief-relay.onrender.com": "", + "https://brief-relay.onrender.com/x": "", + "https://brief-relay.onrender.com/?a=1": "", + "": "", + } { + if got := RelayCallbackURL(in); got != want { + t.Errorf("RelayCallbackURL(%q) = %q, want %q", in, got, want) + } + } + if _, err := NewAppManifest("acme/brief", "cloud", "http://127.0.0.1:1/created", "http://insecure.example"); err == nil { + t.Error("a relay address that is not an https origin was taken for the callback") + } +} + +func TestAppPagesAreGitHubs(t *testing.T) { + if got := ManifestFormURL("acme", true, "s1"); got != "https://github.com/organizations/acme/settings/apps/new?state=s1" { + t.Errorf("organisation form = %q", got) + } + if got := ManifestFormURL("octo", false, "s1"); got != "https://github.com/settings/apps/new?state=s1" { + t.Errorf("personal form = %q", got) + } + org := CreatedApp{Slug: "acme-brief", OwnerLogin: "acme", OwnerType: "Organization"} + if got := org.SettingsURL(); got != "https://github.com/organizations/acme/settings/apps/acme-brief" { + t.Errorf("organisation settings = %q", got) + } + user := CreatedApp{Slug: "octo-brief", OwnerLogin: "octo", OwnerType: "User"} + if got := user.SettingsURL(); got != "https://github.com/settings/apps/octo-brief" { + t.Errorf("personal settings = %q", got) + } + if got := user.InstallURL(); got != "https://github.com/apps/octo-brief/installations/new" { + t.Errorf("install = %q", got) + } +} + +// TestConversionKeepsOnlyThePublicFields: the manifest conversion answers +// with the app's client secret, webhook secret and private key; none of them +// survives the read. +func TestConversionKeepsOnlyThePublicFields(t *testing.T) { + body := `{"id": 1, "slug": "acme-brief", "client_id": "Iv23liAbC", "client_secret": "SECRET-ONE", + "webhook_secret": "SECRET-TWO", "pem": "-----BEGIN RSA PRIVATE KEY-----SECRET-THREE", + "html_url": "https://github.com/apps/acme-brief", "owner": {"login": "acme", "type": "Organization"}}` + app, err := ParseConversion([]byte(body)) + if err != nil { + t.Fatal(err) + } + if app.ClientID != "Iv23liAbC" || app.Slug != "acme-brief" || app.OwnerLogin != "acme" || app.OwnerType != "Organization" { + t.Fatalf("app = %+v", app) + } + if dump, _ := json.Marshal(app); strings.Contains(string(dump), "SECRET") { + t.Fatalf("a secret survived the read: %s", dump) + } + for _, bad := range []string{`{}`, `{"client_id": "bad id!", "slug": "x"}`, `{"client_id": "Iv1", "slug": "../x"}`, `not json`} { + if _, err := ParseConversion([]byte(bad)); err == nil { + t.Errorf("%s accepted", bad) + } + } +} + +// TestWriteAppFileKeepsOnlyTheClientID: the file holds the client id and +// nothing else, reads back through ReadAppClientID, and is written through +// the checkout's root. +func TestWriteAppFileKeepsOnlyTheClientID(t *testing.T) { + root := t.TempDir() + if err := WriteAppFile(root, "Iv23liAbC"); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(root, ".abcd", "relay", AppFile)) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil || len(got) != 1 || got["client_id"] != "Iv23liAbC" { + t.Fatalf("%s = %s (%v)", AppFile, raw, err) + } + if id, err := ReadAppClientID(root); err != nil || id != "Iv23liAbC" { + t.Fatalf("read back %q %v", id, err) + } + if err := WriteAppFile(root, "not an id!"); err == nil { + t.Error("a malformed client id was written") + } + elsewhere := t.TempDir() + linked := t.TempDir() + if err := os.MkdirAll(filepath.Join(linked, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(elsewhere, filepath.Join(linked, ".abcd", "relay")); err != nil { + t.Fatal(err) + } + if err := WriteAppFile(linked, "Iv23liAbC"); err == nil { + t.Error("wrote through a symlinked .abcd/relay") + } + if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { + t.Error("a file landed outside the checkout") + } +} diff --git a/internal/core/session/testdata/blueprint/Dockerfile.golden b/internal/core/session/testdata/blueprint/Dockerfile.golden new file mode 100644 index 000000000..b217a0e49 --- /dev/null +++ b/internal/core/session/testdata/blueprint/Dockerfile.golden @@ -0,0 +1,15 @@ +# abcd session blueprint v0.17.1 sha256:905650d2ef7b98b306cf3fbbeade56f0e6e14064c18d7d22c0d28b055ef47e0b +# The team's own relay for abcd's live brief sessions. It downloads the +# released abcd binary for linux/amd64, checks it against the checksum that +# release published (pinned below when this file was written), and runs the +# relay behind the host's proxy. abcd refuses to overwrite this file once it is +# edited; delete it and run `abcd session blueprint` to write it again. +FROM --platform=linux/amd64 alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +RUN wget -q -O /usr/local/bin/abcd "https://github.com/intentdriven/abcd/releases/download/v0.17.1/abcd-linux-amd64" \ + && echo "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef /usr/local/bin/abcd" | sha256sum -c - \ + && chmod 0755 /usr/local/bin/abcd \ + && adduser -D -H relay +USER relay +ENV PORT=10000 +EXPOSE 10000 +CMD ["sh", "-c", "exec abcd session serve --listen \"0.0.0.0:${PORT}\" --behind-proxy"] diff --git a/internal/core/session/testdata/blueprint/relays.json.golden b/internal/core/session/testdata/blueprint/relays.json.golden new file mode 100644 index 000000000..472dd39fd --- /dev/null +++ b/internal/core/session/testdata/blueprint/relays.json.golden @@ -0,0 +1,6 @@ +{ + "abcd_session_blueprint": "v0.17.1 sha256:45ec4cd2970f601f4559f27d98591f8a2d8305aaf968515d698b774e6ff086c2", + "relays": [ + "https://relay.example.org" + ] +} diff --git a/internal/core/session/testdata/blueprint/render.yaml.golden b/internal/core/session/testdata/blueprint/render.yaml.golden new file mode 100644 index 000000000..be6ba6200 --- /dev/null +++ b/internal/core/session/testdata/blueprint/render.yaml.golden @@ -0,0 +1,39 @@ +# abcd session blueprint v0.17.1 sha256:93b698fcf646080df78ae481a672b4604a833a22ecb983f44491dacc16515fe7 +# The team's own brief session relay: one free web service built from the +# Dockerfile under .abcd/relay/. Deploy it with Render's Deploy button, +# https://render.com/deploy?repo=https://github.com/acme/brief, +# or on Render choose New, then Blueprint, and pick this repository. +# One deploy serves all of the team's sessions. A session lives in the relay's +# memory only, so a redeploy ends every open session; that is why autoDeploy +# is off. abcd refuses to overwrite this file once it is edited. +services: + - type: web + name: abcd-session-relay + runtime: docker + plan: free + dockerfilePath: ./.abcd/relay/Dockerfile + dockerContext: ./.abcd/relay + healthCheckPath: /healthz + autoDeploy: false + envVars: + # The team's own GitHub App, the only kind of sign-in app the relay + # takes. Render's form asks for the secret when the relay is created; + # it is never in git and stays on the relay. The client id is public. + - key: ABCD_SESSION_GITHUB_CLIENT_ID + sync: false + - key: ABCD_SESSION_GITHUB_CLIENT_SECRET + sync: false + # The origins of the session pages this relay admits, as a comma + # list of https origins: abcdesign.app unless the blueprint was told + # others. An entry for a page served below a base path names it + # (https://host/base/), and the sign-in returns only to a page under + # exactly that path. It is not a secret. + - key: ABCD_SESSION_ORIGINS + value: "https://abcdesign.app" + # The repositories this relay serves sessions for, read from the + # checkout's origin when this file was written: a comma list of + # owner/name, or of owners for all of an owner's repositories. It is + # not a secret. Listening beyond loopback, the relay refuses to start + # without it. + - key: ABCD_SESSION_REPOS + value: "acme/brief" diff --git a/internal/core/site/assets.go b/internal/core/site/assets.go index 3b6bae336..8c88aad0b 100644 --- a/internal/core/site/assets.go +++ b/internal/core/site/assets.go @@ -376,10 +376,14 @@ type assetPipe struct { root *os.Root // copies maps a repo-relative source to its output-relative destination. copies map[string]string + // base is the build's base path, which every picture's src sits under. + base string + // project leaves every picture out (a project site, project.go). + project bool } -func newAssetPipe(repoRoot string, root *os.Root) *assetPipe { - return &assetPipe{repoRoot: repoRoot, root: root, copies: map[string]string{}} +func newAssetPipe(repoRoot string, root *os.Root, base string) *assetPipe { + return &assetPipe{repoRoot: repoRoot, root: root, copies: map[string]string{}, base: base} } // Copies lists the rasters to copy, sorted, as (source, destination) pairs. @@ -396,6 +400,12 @@ func (a *assetPipe) Copies() [][2]string { // directory of the page the reference was written in, so `../assets/img/x.png` // resolves the way a reader on GitHub sees it. func (a *assetPipe) render(pageDir, src, alt string, at Source) (string, error) { + // A project site renders no picture: every picture lives under docs/, + // a person's own files, and asking for pictures or offering to make them + // is a later intent (iss-2610150529485165). The picture is left out. + if a.project { + return "", nil + } if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "data:") { return "", &UnsupportedError{Path: at.Path, Line: at.Line, Construct: "remote image", Detail: "every picture is a committed asset under docs/assets/img/; " + quote(src) + " is fetched at render time"} @@ -460,7 +470,7 @@ func (a *assetPipe) render(pageDir, src, alt string, at Source) (string, error) // `/record/adr/adr-1/`. An output-relative src resolves only at the root and // 404s everywhere else — a picture missing on hundreds of pages and present // on the one page anybody previews. - return `<img src="/` + escapeAttr(dst) + `" alt="` + escapeAttr(alt) + `"` + size + ` loading="lazy">`, nil + return `<img src="` + escapeAttr(sitePath(a.base, "/"+dst)) + `" alt="` + escapeAttr(alt) + `"` + size + ` loading="lazy">`, nil } // pngSize reads a PNG's pixel dimensions out of its IHDR chunk, so the page can diff --git a/internal/core/site/assets_test.go b/internal/core/site/assets_test.go index ae484b228..7e991463d 100644 --- a/internal/core/site/assets_test.go +++ b/internal/core/site/assets_test.go @@ -41,7 +41,7 @@ func TestAssetsRefuseAnythingOutsideTheAssetRoot(t *testing.T) { "docs/assets/img/ok.svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>`, "docs/explanation/notes.md": "prose\n", }) - pipe := newAssetPipe(root, mustOpenRoot(t, root)) + pipe := newAssetPipe(root, mustOpenRoot(t, root), "/") at := Source{Path: "docs/explanation/page.md", Line: 3} for _, src := range []string{ @@ -122,7 +122,7 @@ func TestAssetsRefuseExecutableSVG(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { root := assetFixture(t, map[string]string{"docs/assets/img/x.svg": c.svg}) - pipe := newAssetPipe(root, mustOpenRoot(t, root)) + pipe := newAssetPipe(root, mustOpenRoot(t, root), "/") out, err := pipe.render("docs/explanation", "../assets/img/x.svg", "", at) if err == nil { t.Fatalf("inlined an executable SVG into the page:\n%s", out) @@ -179,7 +179,7 @@ func TestAssetsRefuseStyledSVG(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { root := assetFixture(t, map[string]string{"docs/assets/img/x.svg": c.svg}) - pipe := newAssetPipe(root, mustOpenRoot(t, root)) + pipe := newAssetPipe(root, mustOpenRoot(t, root), "/") out, err := pipe.render("docs/explanation", "../assets/img/x.svg", "", at) if err == nil { t.Fatalf("inlined a styled SVG into the page:\n%s", out) @@ -208,7 +208,7 @@ func TestAssetsAcceptDrawings(t *testing.T) { <text x="1" y="9" font-family="sans-serif" font-size="2">hi<tspan dy="1">there</tspan></text> <use xlink:href="#c"/></g></svg>` root := assetFixture(t, map[string]string{"docs/assets/img/x.svg": svg}) - pipe := newAssetPipe(root, mustOpenRoot(t, root)) + pipe := newAssetPipe(root, mustOpenRoot(t, root), "/") out, err := pipe.render("docs/explanation", "../assets/img/x.svg", "", Source{Path: "p.md", Line: 1}) if err != nil { t.Fatalf("a drawing was refused: %v", err) @@ -243,7 +243,7 @@ func TestAssetsStripOnlyTheRootDrawingSize(t *testing.T) { <image href="data:image/webp;base64,AAAA" x="10" y="10" width="42" height="42" clip-path="url(#cp)" preserveAspectRatio="xMidYMid slice"/> <use href="#sy" x="60" y="60" width="12" height="12"/></svg>` root := assetFixture(t, map[string]string{"docs/assets/img/x.svg": svg}) - pipe := newAssetPipe(root, mustOpenRoot(t, root)) + pipe := newAssetPipe(root, mustOpenRoot(t, root), "/") out, err := pipe.render("docs/explanation", "../assets/img/x.svg", "", Source{Path: "p.md", Line: 1}) if err != nil { t.Fatalf("a drawing was refused: %v", err) @@ -284,7 +284,7 @@ func TestAssetsStripOnlyTheRootDrawingSize(t *testing.T) { func TestAssetsStripTouchesTheRootTagAlone(t *testing.T) { svg := `keep width="1" me<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4" width="4" height="4"><rect width="2" height="2"/></svg>` root := assetFixture(t, map[string]string{"docs/assets/img/x.svg": svg}) - pipe := newAssetPipe(root, mustOpenRoot(t, root)) + pipe := newAssetPipe(root, mustOpenRoot(t, root), "/") out, err := pipe.render("docs/explanation", "../assets/img/x.svg", "", Source{Path: "p.md", Line: 1}) if err != nil { t.Fatalf("a drawing was refused: %v", err) @@ -313,7 +313,7 @@ func TestAssetsStripTouchesTheRootTagAlone(t *testing.T) { func TestCommittedSVGAssetsAreDrawings(t *testing.T) { repoRoot := filepath.Join("..", "..", "..") dir := filepath.Join(repoRoot, "docs", "assets", "img") - pipe := newAssetPipe(repoRoot, mustOpenRoot(t, repoRoot)) + pipe := newAssetPipe(repoRoot, mustOpenRoot(t, repoRoot), "/") at := Source{Path: "docs/explanation/page.md", Line: 1} var found int err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { diff --git a/internal/core/site/basepath.go b/internal/core/site/basepath.go new file mode 100644 index 000000000..9bdf052fa --- /dev/null +++ b/internal/core/site/basepath.go @@ -0,0 +1,50 @@ +package site + +// The base path (itd-2610091010348892): the path a site is served below on its +// host. abcd's own site is served at the root of its domain, so its base path +// is "/" and every page links the shared stylesheet as /site.css. A project's +// GitHub Pages address is owner.github.io/<repo>/, so a site built for it links +// /<repo>/site.css instead, and every other root-absolute URL the build writes +// moves under the same prefix — the shape Hugo's baseURL, Astro's and +// VitePress's base and Docusaurus's baseUrl give the same problem. +// +// Every root-absolute URL the build writes goes through sitePath, and the gate +// resolves what it reads against the same prefix, so a build and its check +// agree on where the site's root is. + +import ( + "fmt" + "regexp" + "strings" +) + +// segmentRe is one plain path segment: the URL's unreserved characters, so a +// base path is never escaped, never interpreted and never leaves an attribute. +var segmentRe = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) + +// NormaliseBasePath is the base path s names, with one leading and one +// trailing slash: "" and "/" are the root, "widgets" is "/widgets/". It +// refuses anything but plain path segments — no scheme or host, no empty, "." +// or ".." segment, and no character a URL would have to escape. +func NormaliseBasePath(s string) (string, error) { + if s == "" || s == "/" { + return "/", nil + } + trimmed := strings.TrimSuffix(strings.TrimPrefix(s, "/"), "/") + for _, seg := range strings.Split(trimmed, "/") { + if seg == "." || seg == ".." || !segmentRe.MatchString(seg) { + return "", fmt.Errorf("site: the base path %q is not plain path segments (letters, digits and . _ ~ -, separated by single slashes)", s) + } + } + return "/" + trimmed + "/", nil +} + +// sitePath places the root-absolute site path p ("/record/") under base, a +// path NormaliseBasePath returned. At the root it returns p unchanged, so a +// build that names no base path writes the bytes it always wrote. +func sitePath(base, p string) string { + if base == "" || base == "/" { + return p + } + return base + strings.TrimPrefix(p, "/") +} diff --git a/internal/core/site/basepath_test.go b/internal/core/site/basepath_test.go new file mode 100644 index 000000000..c9d27e51b --- /dev/null +++ b/internal/core/site/basepath_test.go @@ -0,0 +1,281 @@ +package site + +// The base path (itd-2610091010348892): a site served below the root of its +// host — a project's GitHub Pages address, owner.github.io/<repo>/ — links +// every page, stylesheet, script, picture and record route under that path, +// and the gate resolves what it reads against the same prefixed root. + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestNormaliseBasePath(t *testing.T) { + for in, want := range map[string]string{ + "": "/", + "/": "/", + "widgets": "/widgets/", + "/widgets": "/widgets/", + "widgets/": "/widgets/", + "/widgets/": "/widgets/", + "/a/b.c_d~e-f/": "/a/b.c_d~e-f/", + } { + got, err := NormaliseBasePath(in) + if err != nil || got != want { + t.Errorf("NormaliseBasePath(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, in := range []string{ + "..", "/../x/", "/a/../b/", "/./", "//", "/a//b/", "https://example.com/", "//example.com/", + "/a b/", "/a?b/", "/a#b/", "/%2e%2e/", `/a"b/`, "/a<b/", "/a\\b/", "/é/", + } { + if got, err := NormaliseBasePath(in); err == nil { + t.Errorf("NormaliseBasePath(%q) = %q, want a refusal", in, got) + } + } +} + +// rootAbsoluteRe finds every root-absolute URL an attribute carries: an href, +// a src, and the graph's data address. +var rootAbsoluteRe = regexp.MustCompile(`(?:href|src|data-graph)="(/[^"]*)"`) + +// siteFiles reads every file a build wrote, keyed output-relative, the marker +// aside. +func siteFiles(t *testing.T, out string) map[string][]byte { + t.Helper() + files := map[string][]byte{} + err := filepath.WalkDir(out, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, _ := filepath.Rel(out, p) + data, err := os.ReadFile(p) + files[filepath.ToSlash(rel)] = data + return err + }) + if err != nil { + t.Fatal(err) + } + return files +} + +func buildFixtureAt(t *testing.T, f *fixture, base string) (string, map[string][]byte) { + t.Helper() + out := t.TempDir() + if _, err := Build(Request{RepoRoot: f.Root(), OutDir: out, Stamp: fixtureStamp, BasePath: base}); err != nil { + t.Fatalf("build at %q: %v", base, err) + } + return out, siteFiles(t, out) +} + +func TestABasePathPrefixesEveryRootAbsoluteURL(t *testing.T) { + f := newFixture(t) + // Published decisions add the decision log and its dashboard tile, whose + // links the base path must reach too. + f.addDecisions(true) + _, files := buildFixtureAt(t, f, "/widgets/") + seen := 0 + for rel, data := range files { + if !strings.HasSuffix(rel, ".html") { + continue + } + for _, m := range rootAbsoluteRe.FindAllSubmatch(data, -1) { + u := string(m[1]) + if strings.HasPrefix(u, "//") { + continue + } + seen++ + if !strings.HasPrefix(u, "/widgets/") { + t.Errorf("%s links %q, outside the base path /widgets/", rel, u) + } + } + } + if seen == 0 { + t.Fatal("the build carries no root-absolute URL at all, so the test proves nothing") + } + for _, want := range []string{`href="/widgets/site.css"`, `src="/widgets/site.js"`, `href="/widgets/record/"`, `data-graph="/widgets/record.json"`} { + found := false + for _, data := range files { + if bytes.Contains(data, []byte(want)) { + found = true + break + } + } + if !found { + t.Errorf("no page carries %s", want) + } + } +} + +// The default output does not move: a build that names no base path and one +// that names "/" write the same files byte for byte (the golden files pin +// those bytes against the render before the base path existed), and a build +// below a base path differs from the default only by that prefix. +func TestTheDefaultBasePathChangesNoByte(t *testing.T) { + f := newFixture(t) + _, def := buildFixtureAt(t, f, "") + _, root := buildFixtureAt(t, f, "/") + _, widgets := buildFixtureAt(t, f, "/widgets/") + if len(def) != len(root) || len(def) != len(widgets) { + t.Fatalf("file counts differ: default %d, root %d, widgets %d", len(def), len(root), len(widgets)) + } + for rel, want := range def { + if !bytes.Equal(root[rel], want) { + t.Errorf("%s: a build at / differs from the default build", rel) + } + got := widgets[rel] + for _, attr := range []string{"href", "src", "data-graph"} { + got = bytes.ReplaceAll(got, []byte(attr+`="/widgets/`), []byte(attr+`="/`)) + } + if !bytes.Equal(got, want) { + t.Errorf("%s: a build at /widgets/ differs from the default by more than the prefix", rel) + } + } +} + +func TestTheGateResolvesAgainstTheBasePath(t *testing.T) { + f := newFixture(t) + out, _ := buildFixtureAt(t, f, "/widgets/") + + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, BasePath: "/widgets/"}) + if err != nil { + t.Fatal(err) + } + for _, fd := range res.Findings { + if fd.Check == CheckBasePath || (fd.Check == CheckMobile && strings.Contains(fd.Detail, "links the stylesheet")) { + t.Errorf("a build at its own base path was refused: %+v", fd) + } + } + + // The same output judged as if served at the root links stylesheets the + // output does not carry at those paths. + res, err = Check(CheckRequest{RepoRoot: f.Root(), OutDir: out}) + if err != nil { + t.Fatal(err) + } + if !hasFinding(res, CheckMobile, "widgets/site.css") { + t.Errorf("a prefixed build checked at the root resolved its stylesheet anyway: %+v", res.Findings) + } + + // A root-absolute link that lacks the prefix leaves the site. + page := filepath.Join(out, "index.html") + raw, err := os.ReadFile(page) + if err != nil { + t.Fatal(err) + } + raw = bytes.Replace(raw, []byte(`href="/widgets/record/"`), []byte(`href="/record/"`), 1) + if err := os.WriteFile(page, raw, 0o644); err != nil { + t.Fatal(err) + } + res, err = Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, BasePath: "/widgets/"}) + if err != nil { + t.Fatal(err) + } + if !hasFinding(res, CheckBasePath, "/record/") { + t.Errorf("an unprefixed link was not refused: %+v", res.Findings) + } +} + +func TestTheGateRefusesAnUnusableBasePath(t *testing.T) { + f := newFixture(t) + if _, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: t.TempDir(), BasePath: "/../x/"}); err == nil { + t.Fatal("the gate accepted a base path that climbs out of the site") + } + if _, err := Build(Request{RepoRoot: f.Root(), OutDir: t.TempDir(), BasePath: "/a b/"}); err == nil { + t.Fatal("the build accepted a base path that is not plain path segments") + } +} + +func hasFinding(res CheckResult, check, detail string) bool { + for _, fd := range res.Findings { + if fd.Check == check && strings.Contains(fd.Detail, detail) { + return true + } + } + return false +} + +// TestTheBasePathGateIsDeniedAPassOnAnUnparsedPage: a page that does not parse +// is never walked, so the base-path gate cannot report ok for it; a link that +// leaves the site on that page would otherwise disappear behind the parse +// fault. +func TestTheBasePathGateIsDeniedAPassOnAnUnparsedPage(t *testing.T) { + f := newFixture(t) + out, _ := buildFixtureAt(t, f, "/widgets/") + page := filepath.Join(out, "index.html") + raw, err := os.ReadFile(page) + if err != nil { + t.Fatal(err) + } + broken := bytes.Replace(raw, []byte(`<footer class="site-foot">`), []byte(`<footer class="site-foot"><!-- smuggled -->`), 1) + if bytes.Equal(broken, raw) { + t.Fatal("the fixture's index page has no footer to break") + } + if err := os.WriteFile(page, broken, 0o644); err != nil { + t.Fatal(err) + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, BasePath: "/widgets/"}) + if err != nil { + t.Fatal(err) + } + denied := false + for _, fd := range res.Findings { + if fd.Check == CheckBasePath && fd.Where == "index.html" && strings.Contains(fd.Detail, "did not parse") { + denied = true + } + } + if !denied { + t.Fatalf("the base-path gate passed a page it never read: %+v", res.Findings) + } +} + +// Every attribute a page can carry a site URL in is held to the base path: a +// responsive image's every candidate, a video's poster and an SVG reference +// as well as href and src. A URL that starts with the prefix but climbs out +// of it with a `..` segment leaves the site as surely as one without it. +func TestTheGateReadsEveryURLAttributeAndRefusesAClimb(t *testing.T) { + f := newFixture(t) + out, _ := buildFixtureAt(t, f, "/widgets/") + page := filepath.Join(out, "index.html") + raw, err := os.ReadFile(page) + if err != nil { + t.Fatal(err) + } + inside := `<img src="/widgets/a.png" srcset="/widgets/a.png 1x, /widgets/a2.png 2x" alt="">` + + `<video poster="/widgets/p.png"></video><svg><use xlink:href="/widgets/s.svg#i"></use></svg>` + outside := `<img src="/widgets/b.png" srcset="/widgets/b.png 1x, /srcset-out.png 2x" alt="">` + + `<video poster="/poster-out.png"></video><svg><use xlink:href="/xlink-out.svg#i"></use></svg>` + + `<a href="/widgets/../climb-out/">x</a><img src="/widgets/./../dots-out.png" alt="">` + + `<a href="/widgets/%2e%2e/encoded-out/">x</a>` + marker := []byte(`<main id="app">`) + if !bytes.Contains(raw, marker) { + t.Fatal("the fixture's index page has no main element to add to") + } + if err := os.WriteFile(page, bytes.Replace(raw, marker, append(marker, inside+outside...), 1), 0o644); err != nil { + t.Fatal(err) + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, BasePath: "/widgets/"}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"/srcset-out.png", "/poster-out.png", "/xlink-out.svg", "/widgets/../climb-out/", + "/widgets/./../dots-out.png", "/widgets/%2e%2e/encoded-out/"} { + if !hasFinding(res, CheckBasePath, want) { + t.Errorf("the gate let %s through", want) + } + } + for _, fd := range res.Findings { + if fd.Check != CheckBasePath { + continue + } + for _, ok := range []string{"/widgets/a.png", "/widgets/a2.png", "/widgets/p.png", "/widgets/s.svg"} { + if strings.Contains(fd.Detail, ok) { + t.Errorf("a URL inside the base path was refused: %+v", fd) + } + } + } +} diff --git a/internal/core/site/build.go b/internal/core/site/build.go index 6bcc8b9da..86c878970 100644 --- a/internal/core/site/build.go +++ b/internal/core/site/build.go @@ -4,10 +4,11 @@ package site // // The order is: read the committed inputs (manifest, allowlist, baseline, // package metadata), read the record ONCE through the lint engine's own scan, -// walk git history ONCE, then compose. Nothing here reaches the network, and -// nothing writes outside the output directory: every write goes through an -// os.Root opened at the destination, so a path arriving from a manifest cannot -// escape it even by construction. +// walk git history ONCE (the composed route only: a project site reads none), +// then compose. Nothing here reaches the network, and nothing writes outside +// the output directory: every write goes through an os.Root opened at the +// destination, so a path arriving from a manifest cannot escape it even by +// construction. // // The build is transport-agnostic. It returns a Result describing what it wrote; // the front door prints it. @@ -24,6 +25,7 @@ import ( "strings" "syscall" + "github.com/intentdriven/abcd/internal/core/ahoy" "github.com/intentdriven/abcd/internal/core/changelog" "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/core/statusblock" @@ -240,8 +242,48 @@ type RepoMeta struct { } // LoadRepoMeta reads the package manifest. An absent one is a state: the pages -// that would carry a forge link render without it. +// that would carry a forge link render without it. A repository address the +// manifest does not give is the origin remote's on github.com, when it has one: +// a managed repository need carry no package manifest, and the line every page +// opens with links the repository (itd-2610091010348892). func LoadRepoMeta(repoRoot string) (RepoMeta, error) { + m, err := loadPackageMeta(repoRoot) + if err != nil || m.Repository != "" { + return m, err + } + if repo, rerr := ahoy.GitHubRepo(repoRoot); rerr == nil { + m.Repository = "https://github.com/" + repo + } + return m, nil +} + +// loadRepoMetaFor reads the repository facts a build or a gate renders from. +// A composed site reads the package manifest (LoadRepoMeta). A project site +// reads the origin remote alone (projectRepoMeta): the manifest is a file a +// person writes, so its name, author, licence and repository address are not +// something abcd wrote, and on a fork its address is the upstream's. +func loadRepoMetaFor(repoRoot string, project bool) (RepoMeta, error) { + if project { + return projectRepoMeta(repoRoot), nil + } + return LoadRepoMeta(repoRoot) +} + +// projectRepoMeta is a project site's repository facts: the repository's +// address on GitHub and its name there, both from the origin remote, and +// nothing else. With no GitHub origin there is no address and no name, and +// the pages that would carry them render without them. +func projectRepoMeta(repoRoot string) RepoMeta { + repo, err := ahoy.GitHubRepo(repoRoot) + if err != nil { + return RepoMeta{} + } + _, name, _ := strings.Cut(repo, "/") + return RepoMeta{Name: name, Repository: "https://github.com/" + repo} +} + +// loadPackageMeta reads the package manifest alone. +func loadPackageMeta(repoRoot string) (RepoMeta, error) { root, err := os.OpenRoot(repoRoot) if err != nil { return RepoMeta{}, err @@ -293,6 +335,14 @@ type Request struct { // file is refused there by name and completed by `site build` or // `site setup` alone. LeaveUI bool + // BasePath is the path the site is served below on its host ("/widgets/" + // for a project's GitHub Pages address); empty is the root. Every + // root-absolute URL the build writes is placed under it (basepath.go). + BasePath string + // Project builds a project site (project.go): `.abcd/site.json` holds + // its settings, and every page is derived from what abcd writes. A build + // whose flag and settings file disagree is refused. + Project bool } // Result describes what a build wrote. @@ -367,6 +417,10 @@ func build(req Request, written *labelsAdded) (Result, error) { if err != nil { return Result{}, err } + base, err := NormaliseBasePath(req.BasePath) + if err != nil { + return Result{}, err + } // The two version instructions are opposites — "stamp exactly this" and // "stamp no version at all" — so a build given both would have to silently @@ -401,21 +455,31 @@ func build(req Request, written *labelsAdded) (Result, error) { } return Result{}, err } + if err := routeAgrees(manifest, req.Project); err != nil { + return Result{}, err + } // A ui.json written before a label existed gains that label with its // default words, and nothing else changes (the TG1 ruling); a label the - // file carries, blank or not, is still judged by LoadUI. + // file carries, blank or not, is still judged by LoadUI. A project site + // reads its interface words from the binary and touches no file. var addedLabels []string - if !req.LeaveUI { - if addedLabels, err = addMissingLabels(repoRoot, manifest.UIStrings); err != nil { + var ui UI + if manifest.Project() { + if ui, err = projectUI(); err != nil { + return Result{}, err + } + } else { + if !req.LeaveUI { + if addedLabels, err = addMissingLabels(repoRoot, manifest.UIStrings); err != nil { + return Result{}, err + } + written.file, written.labels = manifest.UIStrings, addedLabels + } + if ui, err = LoadUI(repoRoot, manifest.UIStrings); err != nil { return Result{}, err } - written.file, written.labels = manifest.UIStrings, addedLabels - } - ui, err := LoadUI(repoRoot, manifest.UIStrings) - if err != nil { - return Result{}, err } - repo, err := LoadRepoMeta(repoRoot) + repo, err := loadRepoMetaFor(repoRoot, manifest.Project()) if err != nil { return Result{}, err } @@ -424,9 +488,9 @@ func build(req Request, written *labelsAdded) (Result, error) { if err != nil { return Result{}, err } - lintCfg, err := lint.LoadConfigInRoot(lintRoot, ".abcd/record-lint.json") + lintCfg, err := loadRecordConfig(lintRoot, manifest.Project()) lintRoot.Close() - if err != nil && !os.IsNotExist(err) { + if err != nil { return Result{}, err } graph, err := lint.LoadRecordGraph(lintCfg, repoRoot) @@ -442,9 +506,16 @@ func build(req Request, written *labelsAdded) (Result, error) { if err != nil { return Result{}, err } - hist, err := LoadHistory(repoRoot) - if err != nil { - return Result{}, err + // A project site shows nothing read from git history (project.go), so + // the project route does not walk it at all: hist stays nil, and every + // page and record.json are built without it. + var hist *History + if !manifest.Project() { + h, err := LoadHistory(repoRoot) + if err != nil { + return Result{}, err + } + hist = &h } stamp := req.Stamp @@ -486,15 +557,36 @@ func build(req Request, written *labelsAdded) (Result, error) { c := &composer{ repoRoot: repoRoot, root: srcRoot, manifest: manifest, ui: ui, repo: repo, - assets: newAssetPipe(repoRoot, srcRoot), graph: graph, history: hist, stamp: stamp, - beta: isPreOne(version), version: version, releaseDate: releaseDate, + assets: newAssetPipe(repoRoot, srcRoot, base), graph: graph, stamp: stamp, + beta: isPreOne(version), version: version, releaseDate: releaseDate, base: base, + identityLoc: manifest.Identity, + } + if hist != nil { + c.history = *hist + } + compose := c.ComposeLanding + if manifest.Project() { + if c.identityLoc, err = projectIdentity(repoRoot); err != nil { + return Result{}, err + } + c.project, c.assets.project = true, true + compose = c.ComposeAbout + // Nothing abcd wrote for the about page is an empty site, refused here + // as setup refuses it, before anything is written. + about, err := c.aboutSources() + if err != nil { + return Result{}, err + } + if about.empty() { + return Result{}, ErrProjectEmpty + } } - html, err := c.ComposeLanding() + html, err := compose() if err != nil { return Result{}, err } - export, err := BuildRecordExport(repoRoot, manifest.Checks.UnresolvedReferenceBaseline, graph, principles, hist, stamp, manifest.Record) + export, err := buildRecordExport(repoRoot, manifest.Checks.UnresolvedReferenceBaseline, graph, principles, hist, stamp, manifest.Record) if err != nil { return Result{}, err } @@ -506,9 +598,13 @@ func build(req Request, written *labelsAdded) (Result, error) { // The record explorer. Its optional inputs — a bibliography, a principle // store, a changelog — each omit the page they feed rather than failing. - bib, err := LoadBibliography(repoRoot, CSLRelPath, AcknowledgementsRelPath) - if err != nil { - return Result{}, err + // A project site has no references page: its headings and numbering are + // ACKNOWLEDGEMENTS.md's, a person's own file (project.go). + var bib *Bibliography + if !manifest.Project() { + if bib, err = LoadBibliography(repoRoot, CSLRelPath, AcknowledgementsRelPath); err != nil { + return Result{}, err + } } recordRoot := "" if len(lintCfg.Roots) > 0 { @@ -601,8 +697,18 @@ func build(req Request, written *labelsAdded) (Result, error) { } } res.Pages = len(pages) - for _, cp := range copiedSources { - data, err := fsutil.ReadGuardedInRoot(srcRoot, cp.src, maxAssetBytes) + // A project site's static files are the binary's own: nothing is read + // from the repository. + statics, readStatic := copiedSources, func(src string) ([]byte, error) { + return fsutil.ReadGuardedInRoot(srcRoot, src, maxAssetBytes) + } + if manifest.Project() { + statics, readStatic = projectCopies, func(src string) ([]byte, error) { + return setupSources.ReadFile("setupsrc/" + src) + } + } + for _, cp := range statics { + data, err := readStatic(cp.src) if err != nil { if os.IsNotExist(err) { continue @@ -614,8 +720,10 @@ func build(req Request, written *labelsAdded) (Result, error) { } } // The installer. A repository that commits no template serves no /install.sh - // — the same graceful absence every other copied source has. + // — the same graceful absence every other copied source has. A project + // site serves none: the template is a site source. switch tmpl, err := fsutil.ReadGuardedInRoot(srcRoot, installTemplateRelPath, maxAssetBytes); { + case manifest.Project(): case err == nil: if err := write(installScriptName, renderInstallScript(tmpl, stamp)); err != nil { return Result{}, err @@ -666,6 +774,9 @@ type Status struct { // board that exits non-zero answers the question by refusing to answer it. OutRefused string `json:"out_refused,omitempty"` Chapters int `json:"chapters"` + // Project says the file holds a project site's settings: no chapters and + // no ui.json, every page built from what abcd writes (project.go). + Project bool `json:"project"` IssueLedge bool `json:"issue_ledger"` Version string `json:"version"` Commit string `json:"commit"` @@ -682,11 +793,12 @@ func Describe(repoRoot, outDir string) (Status, error) { switch { case err == nil: st.Manifest = true + st.Project = m.Project() st.Chapters = len(m.Home.Chapters) st.IssueLedge = m.Record.IssueLedger st.UIPath = m.UIStrings if root, rerr := os.OpenRoot(repoRoot); rerr == nil { - if _, serr := root.Stat(m.UIStrings); serr == nil { + if _, serr := root.Stat(m.UIStrings); serr == nil && !m.Project() { st.UIStrings = true } root.Close() diff --git a/internal/core/site/check.go b/internal/core/site/check.go index 5c2b61c83..d37443f3c 100644 --- a/internal/core/site/check.go +++ b/internal/core/site/check.go @@ -151,12 +151,13 @@ const ( CheckBaseline = "baseline" CheckMobile = "mobile" CheckFigureLabels = "figure-labels" + CheckBasePath = "base-path" ) // CheckNames is every check this verb runs, in the order it runs them. var CheckNames = []string{ CheckProvenance, CheckHero, CheckBannedTokens, CheckSnippets, - CheckBaseline, CheckMobile, CheckFigureLabels, + CheckBaseline, CheckMobile, CheckFigureLabels, CheckBasePath, } // maxOutputPageBytes bounds one emitted page read. @@ -190,6 +191,12 @@ type CheckRequest struct { // Lanes is the state-file reader a build the check makes hands the Status // page, as Request.Lanes; nil reads as an absent state file. Lanes statusblock.LaneReader + // BasePath is the path the output is served below, as Request.BasePath: + // a root-absolute URL resolves against it, and one outside it is a + // finding. Empty is the root. + BasePath string + // Project gates a project site, as Request.Project builds one. + Project bool } // CheckFinding is one failure. Every finding names the check that raised it and @@ -226,7 +233,7 @@ type CheckResult struct { // OK reports whether the run found nothing that fails it. func (r CheckResult) OK() bool { return len(r.Findings) == 0 } -// checker carries everything the seven checks read, gathered once. +// checker carries everything the eight checks read, gathered once. type checker struct { repoRoot string // root is repoRoot as an os.Root containment scope, shared by every read of a @@ -250,6 +257,13 @@ type checker struct { sources map[string]*sourceFile // styles are the parsed stylesheets the pages link, keyed by output path. styles map[string]*styleSheet + // base is the path the output is served below (basepath.go). + base string + // identityLoc is where the Identity block lives (the composition's + // reference, or a project site's positioning pointer). + identityLoc BlockRef + // project gates a project site (project.go). + project bool res *CheckResult } @@ -274,6 +288,10 @@ func Check(req CheckRequest) (CheckResult, error) { if err != nil { return CheckResult{}, err } + base, err := NormaliseBasePath(req.BasePath) + if err != nil { + return CheckResult{}, err + } // Every collection is non-nil from the start: the JSON envelope is a machine // surface, and a reader that has to tell `null` from `[]` is a reader that @@ -284,7 +302,7 @@ func Check(req CheckRequest) (CheckResult, error) { Findings: []CheckFinding{}, Notes: []CheckFinding{}, } if ok, _ := fsutil.Exists(filepath.Join(outDir, "index.html")); !ok { - if _, err := Build(Request{RepoRoot: repoRoot, OutDir: outDir, Lanes: req.Lanes, LeaveUI: true}); err != nil { + if _, err := Build(Request{RepoRoot: repoRoot, OutDir: outDir, Lanes: req.Lanes, LeaveUI: true, BasePath: base, Project: req.Project}); err != nil { return CheckResult{}, err } res.Built = true @@ -297,11 +315,24 @@ func Check(req CheckRequest) (CheckResult, error) { } return CheckResult{}, err } - ui, err := LoadUI(repoRoot, manifest.UIStrings) + if err := routeAgrees(manifest, req.Project); err != nil { + return CheckResult{}, err + } + // A project site's interface words are the binary's, and its identity + // block the one abcd wrote and `.abcd/positioning.json` points at. + ui, identityLoc := UI{}, manifest.Identity + if manifest.Project() { + ui, err = projectUI() + if err == nil { + identityLoc, err = projectIdentity(repoRoot) + } + } else { + ui, err = LoadUI(repoRoot, manifest.UIStrings) + } if err != nil { return CheckResult{}, err } - repo, err := LoadRepoMeta(repoRoot) + repo, err := loadRepoMetaFor(repoRoot, manifest.Project()) if err != nil { return CheckResult{}, err } @@ -328,14 +359,19 @@ func Check(req CheckRequest) (CheckResult, error) { c := &checker{ repoRoot: repoRoot, root: srcRoot, outDir: outDir, manifest: manifest, ui: ui, repo: repo, tokens: tokens, pages: map[string]*htmlNode{}, sources: map[string]*sourceFile{}, - styles: map[string]*styleSheet{}, res: &res, + styles: map[string]*styleSheet{}, res: &res, base: base, + identityLoc: identityLoc, project: manifest.Project(), } - c.identity, c.hasBlock = readIdentity(repoRoot, manifest) + c.identity, c.hasBlock = readIdentity(repoRoot, identityLoc) if err := c.readPages(); err != nil { return CheckResult{}, err } c.checkProvenance() + if c.project { + c.checkProjectSources() + c.checkProjectTitle() + } c.checkHero() c.checkBannedTokens() c.checkSnippets() @@ -344,6 +380,7 @@ func Check(req CheckRequest) (CheckResult, error) { } c.checkMobile() c.checkFigureLabels() + c.checkBasePath() sortFindings(res.Findings) sortFindings(res.Notes) @@ -357,9 +394,12 @@ func Check(req CheckRequest) (CheckResult, error) { const docsLintConfigRelPath = ".abcd/docs-lint.json" // readIdentity resolves the canonical Identity block, or reports its absence. -func readIdentity(repoRoot string, m Manifest) (positioning.Block, bool) { +func readIdentity(repoRoot string, loc BlockRef) (positioning.Block, bool) { + if loc.File == "" { + return positioning.Block{}, false + } blk, err := positioning.ParseBlock(repoRoot, positioning.BlockLocation{ - File: m.Identity.File, Heading: m.Identity.Heading, + File: loc.File, Heading: loc.Heading, }) if err != nil { return positioning.Block{}, false @@ -444,7 +484,13 @@ func (c *checker) readPages() error { // nothing, print `ok` — the report telling a reader a gate held // when it never examined the page. Deny each page-walking gate that // pass, so a genuine finding on an unparsed page cannot disappear. - for _, name := range []string{CheckHero, CheckBannedTokens, CheckSnippets, CheckMobile, CheckFigureLabels} { + // The base-path gate walks the pages only below a base path; at + // the root every root-absolute URL is inside the site, read or not. + gates := []string{CheckHero, CheckBannedTokens, CheckSnippets, CheckMobile, CheckFigureLabels} + if c.base != "/" { + gates = append(gates, CheckBasePath) + } + for _, name := range gates { c.fail(name, rel, "", "this page did not parse, so this gate could not examine it") } continue @@ -712,7 +758,7 @@ func sourcedTexts(uses []textUse) map[string]bool { // identitySpan is the data-src the Identity block's text carries elsewhere on // the page, so a title finding points at the same place a hero finding does. func (c *checker) identitySpan() string { - return c.manifest.Identity.File + "#" + slug(c.manifest.Identity.Heading) + return c.identityLoc.File + "#" + slug(c.identityLoc.Heading) } // elementPath renders a text node's position as its ancestor chain, so a @@ -793,7 +839,10 @@ func (c *checker) generatorWords() generatorWords { addPhrase(seg) } } - // The package's own metadata, from `.claude-plugin/plugin.json`. + // The package's own metadata, from `.claude-plugin/plugin.json`; on a + // project site, the repository's name and handle from its origin remote + // alone (projectRepoMeta), so nothing a person wrote for the manifest + // passes here. addName(c.repo.Name) addName(c.repo.AuthorName) addName(c.repo.License) @@ -968,8 +1017,13 @@ func (g generatorWords) covers(text string) bool { if g.phrases[strings.ToLower(collapseSpaces(piece))] || g.attribution[strings.ToLower(collapseSpaces(piece))] { continue } - for _, field := range strings.Fields(piece) { - if !g.token(field) { + fields := strings.Fields(piece) + for i := 0; i < len(fields); i++ { + if n := g.nameRun(fields[i:]); n > 0 { + i += n - 1 + continue + } + if !g.token(fields[i]) { return false } } @@ -977,6 +1031,21 @@ func (g generatorWords) covers(text string) bool { return true } +// nameRun is how many of fields, from the first, spell one name of more than +// one word (the package's author, a contributor), longest first; 0 when none +// does. The footer prints "© <year> <author>", so a two-word author shares a +// piece with the year and is read whole there. Only names are read this way: +// a run of interface strings is still not a sentence the generator may print. +func (g generatorWords) nameRun(fields []string) int { + for n := len(fields); n > 1; n-- { + run := strings.ToLower(strings.Join(fields[:n], " ")) + if g.names[run] || g.attribution[run] { + return n + } + } + return 0 +} + // token reports whether one whitespace-delimited word is something the // generator may add on its own. func (g generatorWords) token(field string) bool { @@ -1041,6 +1110,11 @@ func (c *checker) checkHero() { if !c.hasBlock { // A repository that records no Identity block renders a hero without one // (graceful absence, build.go); there is nothing to compare against. + if c.project { + c.note(CheckHero, positioning.ConfigRelPath, + "no identity block abcd wrote is recorded; the about page renders without its hero") + return + } c.note(CheckHero, c.manifest.Identity.File, "no Identity block at %q; the hero renders without an eyebrow, tagline and pitch", c.manifest.Identity.Heading) return @@ -1058,11 +1132,16 @@ func (c *checker) checkHero() { c.fail(CheckHero, page, c.identitySpan(), "the page renders no .hero section, and the manifest composes one from %s", c.manifest.Home.Hero.Page) } + if page == homePageRel && c.project { + c.fail(CheckHero, page, c.identitySpan(), + "the about page renders no .hero section, and the repository records an identity block") + } continue } for _, want := range heroSpans { text := want.field(c.identity) - el := findElement(hero, func(n *htmlNode) bool { return n.Name == "p" && n.HasClass(want.class) }) + // A project site's about page sets the tagline as its heading. + el := findElement(hero, func(n *htmlNode) bool { return (n.Name == "p" || n.Name == "h1") && n.HasClass(want.class) }) switch { case el == nil && text == "": // The block declares none; the hero renders none. @@ -1485,10 +1564,16 @@ func (c *checker) stylesheetsFor(page string, doc *htmlNode) []*styleSheet { // stylesheet is linked that way precisely because the pages that link it // sit at every depth the site has, so resolving it per-page would look // for one stylesheet in as many places as there are routes and find it - // in none of them. + // in none of them. The served root is the base path: a site served at + // /widgets/ keeps its stylesheet at /widgets/site.css, and an href + // outside the base names a file the site does not serve. rel := "" if strings.HasPrefix(href, "/") { - rel = path.Clean(strings.TrimPrefix(href, "/")) + if !strings.HasPrefix(href, c.base) { + c.fail(CheckMobile, page, "", "links the stylesheet %s, which is outside the base path %s the site is served below", quote(href), c.base) + continue + } + rel = path.Clean(strings.TrimPrefix(href, c.base)) } else { rel = path.Clean(path.Join(path.Dir(page), href)) } @@ -1706,3 +1791,63 @@ func figureLabels(fig *htmlNode) []string { } return out } + +// basePathAttrs are the attributes a page carries a site URL in: links and +// form targets, every embedded resource (a responsive image's every srcset +// candidate and a video's poster among them), an SVG reference, and the +// graph's data address. +var basePathAttrs = []string{"href", "src", "srcset", "poster", "action", "xlink:href", "data-graph"} + +// attrURLs are the URLs one attribute value carries: each candidate of a +// srcset ("url 1x, url 2x"), the whole value of any other. +func attrURLs(attr, v string) []string { + if attr != "srcset" { + return []string{v} + } + var out []string + for _, cand := range strings.Split(v, ",") { + if f := strings.Fields(cand); len(f) > 0 { + out = append(out, f[0]) + } + } + return out +} + +// climbs reports whether a URL's path has a `..` segment, written plainly or +// percent-encoded, which a browser resolves before it requests the URL: a +// URL that starts with the base path and climbs out of it leaves the site. +func climbs(u string) bool { + if i := strings.IndexAny(u, "?#"); i >= 0 { + u = u[:i] + } + for _, seg := range strings.Split(u, "/") { + switch strings.ToLower(seg) { + case "..", ".%2e", "%2e.", "%2e%2e": + return true + } + } + return false +} + +// checkBasePath asserts every root-absolute URL a page carries sits under the +// base path the site is served below. A site at owner.github.io/<repo>/ that +// links /record/ sends the reader to owner.github.io/record/, which is not +// this site at all. At the root base every root-absolute URL is inside it. +func (c *checker) checkBasePath() { + if c.base == "/" { + return + } + for _, page := range c.res.Pages { + for _, n := range findElements(c.pages[page], func(*htmlNode) bool { return true }) { + for _, attr := range basePathAttrs { + for _, u := range attrURLs(attr, n.Attr(attr)) { + if !strings.HasPrefix(u, "/") || strings.HasPrefix(u, "//") || (strings.HasPrefix(u, c.base) && !climbs(u)) { + continue + } + c.fail(CheckBasePath, page, n.Attr("data-src"), "<%s %s=%s> links outside the base path %s the site is served below", + n.Name, attr, quote(u), c.base) + } + } + } + } +} diff --git a/internal/core/site/check_test.go b/internal/core/site/check_test.go index 7155f0b52..6761e61d8 100644 --- a/internal/core/site/check_test.go +++ b/internal/core/site/check_test.go @@ -167,8 +167,8 @@ func TestCheckPassesAWellFormedRender(t *testing.T) { t.Errorf("pages = %d, composed = %d; the record rendering should be read for the mobile gate", len(res.Pages), len(res.Composed)) } - if len(res.Checks) != 7 { - t.Errorf("checks run = %v, want seven", res.Checks) + if len(res.Checks) != 8 { + t.Errorf("checks run = %v, want eight", res.Checks) } } @@ -278,6 +278,25 @@ func TestGeneratorWordsReadNamesAndCounts(t *testing.T) { } } +// TestGeneratorWordsReadAMultiWordNameBesideAYear: the footer prints +// "© <year> <author>", and an author's name may be two words. The name is the +// package's own metadata, so it is read whole wherever it stands in a piece; +// its words one by one are not names, and a sentence made of them still fails. +func TestGeneratorWordsReadAMultiWordNameBesideAYear(t *testing.T) { + g := generatorWords{ + phrases: map[string]bool{"fixture author": true}, names: map[string]bool{"fixture author": true, "mit": true}, + attribution: map[string]bool{}, exists: func(string) bool { return false }, + } + if !g.covers("© 2026 Fixture Author") { + t.Error(`covers("© 2026 Fixture Author") = false; the year and the author's name are both the generator's to print`) + } + for _, bad := range []string{"© 2026 Fixture", "Author of record", "2026 Fixture Author wrote this"} { + if g.covers(bad) { + t.Errorf("covers(%q) = true; nothing entitles the generator to say it", bad) + } + } +} + // TestCheckAccountsForAPageThatNamesItself covers the explorer's title shape: // a page that names itself reads `<page> · <project>`, and BOTH halves are // held up — the tail against the Identity block, the head against what the diff --git a/internal/core/site/compose.go b/internal/core/site/compose.go index afd658a53..5bf152555 100644 --- a/internal/core/site/compose.go +++ b/internal/core/site/compose.go @@ -82,8 +82,21 @@ type composer struct { // version and releaseDate describe the newest dated CHANGELOG heading. version string releaseDate string + // base is the build's base path (basepath.go). + base string + // identityLoc is where the identity block lives: the composition's + // identity reference, or on the project route the block + // `.abcd/positioning.json` points at (project.go). + identityLoc BlockRef + // project is the project route: the about page, the binary's assets, and + // nothing read from a person's own files (project.go). + project bool } +// at places the root-absolute site path p under the build's base path. Every +// root-absolute URL a page carries is written through it. +func (c *composer) at(p string) string { return sitePath(c.base, p) } + // docPage is one loaded source page. type docPage struct { Rel string @@ -124,7 +137,9 @@ func (c *composer) renderer(p *docPage) *Renderer { Image: func(src, alt string, at Source) (string, error) { return c.assets.render(p.Dir, src, alt, at) }, - Link: func(href string, at Source) string { return siteHref(p.Dir, href, c.repo.Repository, c.rendersDocs()) }, + Link: func(href string, at Source) string { + return siteHref(p.Dir, href, c.repo.Repository, c.rendersDocs(), c.base) + }, } } @@ -158,7 +173,7 @@ func (c *composer) rendersDocs() bool { return c.manifest.Docs.Index != "" } // moment somebody follows it. When the record explorer ships those targets get // real pages and this arm narrows; a broken link in the meantime is not an // acceptable placeholder. -func siteHref(pageDir, href, forge string, docs bool) string { +func siteHref(pageDir, href, forge string, docs bool, base string) string { switch { case href == "", strings.HasPrefix(href, "http://"), @@ -190,7 +205,7 @@ func siteHref(pageDir, href, forge string, docs bool) string { route = "" } } - out := "/docs/" + out := sitePath(base, "/docs/") if route != "" { out += route + "/" } @@ -279,21 +294,40 @@ func (c *composer) headWith(pageTitle, script string) string { b.WriteString(`<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wdth,wght@12..96,75..100,300..800&family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">` + "\n") // Root-absolute, because the shared assets sit at one served path and the // pages that link them sit at every depth the site has. - b.WriteString(`<link rel="stylesheet" href="/site.css">` + "\n") - b.WriteString(`<script src="/site.js" defer></script>` + "\n") + b.WriteString(`<link rel="stylesheet" href="` + escapeAttr(c.at("/site.css")) + `">` + "\n") + b.WriteString(`<script src="` + escapeAttr(c.at("/site.js")) + `" defer></script>` + "\n") if script != "" { - b.WriteString(`<script src="` + escapeAttr(script) + `" defer></script>` + "\n") + b.WriteString(`<script src="` + escapeAttr(c.at(script)) + `" defer></script>` + "\n") } b.WriteString("</head>\n<body>\n") + b.WriteString(c.managedLine()) return b.String() } +// managedLine is the line every page opens with: the site is the record of an +// abcd-managed repository, linking to that repository where the build knows its +// address. The words are the allowlist's, so the line adds nothing the +// provenance gate cannot account for. They sit in a <small>, whose default +// rendering is a small line, so a repository whose own stylesheet predates +// the `.managed` rule (setup keeps a stylesheet the repository already has) +// still shows a small line at the top rather than a paragraph of body text. +func (c *composer) managedLine() string { + words := escapeText(c.ui.ManagedRecord) + if c.repo.Repository != "" { + words = `<a href="` + escapeAttr(c.repo.Repository) + `">` + words + `</a>` + } + return `<p class="managed"><small>` + words + `</small></p>` +} + // identity reads the canonical Identity block. A repository that records none // renders the page without it rather than failing: the hero's headline and lede // come from the rationale page, and those are what carry the page. func (c *composer) identity() (positioning.Block, bool) { + if c.identityLoc.File == "" { + return positioning.Block{}, false + } blk, err := positioning.ParseBlock(c.repoRoot, positioning.BlockLocation{ - File: c.manifest.Identity.File, Heading: c.manifest.Identity.Heading, + File: c.identityLoc.File, Heading: c.identityLoc.Heading, }) if err != nil { return positioning.Block{}, false @@ -303,7 +337,7 @@ func (c *composer) identity() (positioning.Block, bool) { // identitySrc is the provenance of every span the Identity block supplies. func (c *composer) identitySrc() string { - return srcAttr(c.manifest.Identity.File, slug(c.manifest.Identity.Heading)) + return srcAttr(c.identityLoc.File, slug(c.identityLoc.Heading)) } // brandName is the short handle the header shows beside the mark: the first word @@ -329,10 +363,10 @@ func (c *composer) header() string { return c.headerFor("") } func (c *composer) headerFor(active string) string { var b strings.Builder b.WriteString(`<header class="site-head"><div class="wrap">`) - b.WriteString(`<a class="brand" href="/">`) + b.WriteString(`<a class="brand" href="` + escapeAttr(c.at("/")) + `">`) // The mark is a committed asset like any other picture; the stylesheet sizes // it, so its own dimensions ride along unchanged. A repository without one - // gets a wordmark. + // gets a wordmark, and so does a project site, which renders no picture. if logo, err := c.assets.render("docs/assets/img", "logo.png", "", Source{Path: ManifestRelPath}); err == nil { b.WriteString(logo) } @@ -350,13 +384,16 @@ func (c *composer) headerFor(active string) string { } return ` class="on" aria-current="page"` } - b.WriteString(`<a href="/#` + escapeAttr(c.firstChapterAnchor()) + `">` + escapeText(c.ui.NavStory) + `</a>`) - b.WriteString(`<a href="/#` + escapeAttr(c.installChapterAnchor()) + `">` + escapeText(c.ui.NavInstall) + `</a>`) + // A project site's about page has no composed chapters to point into. + if !c.project { + b.WriteString(`<a href="` + escapeAttr(c.at("/#"+c.firstChapterAnchor())) + `">` + escapeText(c.ui.NavStory) + `</a>`) + b.WriteString(`<a href="` + escapeAttr(c.at("/#"+c.installChapterAnchor())) + `">` + escapeText(c.ui.NavInstall) + `</a>`) + } if c.rendersDocs() { - b.WriteString(`<a href="/docs/">` + escapeText(c.ui.NavDocs) + `</a>`) + b.WriteString(`<a href="` + escapeAttr(c.at("/docs/")) + `">` + escapeText(c.ui.NavDocs) + `</a>`) } if c.manifest.Pages.resolve().explorer { - b.WriteString(`<a href="/record/"` + on("/record/") + `>` + escapeText(c.ui.NavRecord) + `</a>`) + b.WriteString(`<a href="` + escapeAttr(c.at("/record/")) + `"` + on("/record/") + `>` + escapeText(c.ui.NavRecord) + `</a>`) } if c.repo.Repository != "" { b.WriteString(`<a class="gh" href="` + escapeAttr(c.repo.Repository) + `">` + escapeText(c.forgeLabel()) + ` ↗</a>`) @@ -1097,7 +1134,7 @@ func (c *composer) install(p *docPage, ch Chapter) (string, error) { // a dead link on the one page that is asking for trust. read := "" if t.isOS && hasCode && servesInstallScript { - read = `<p class="small muted readscript"><a href="/` + installScriptName + `">` + + read = `<p class="small muted readscript"><a href="` + escapeAttr(c.at("/"+installScriptName)) + `">` + escapeText(c.ui.ReadScript) + `</a></p>` } hidden := " hidden" @@ -1158,6 +1195,11 @@ func (c *composer) featureBlock(f *Feature) (string, error) { if !ok { return "", nil } + return c.featureBlockFor(node, f) +} + +// featureBlockFor quotes the parts f names of one shipped intent. +func (c *composer) featureBlockFor(node lint.RecordNode, f *Feature) (string, error) { data, err := fsutil.ReadGuardedInRoot(c.root, node.Path, maxPageBytes) if err != nil { return "", err diff --git a/internal/core/site/credential.go b/internal/core/site/credential.go index e853f8a2c..d2c20fcc5 100644 --- a/internal/core/site/credential.go +++ b/internal/core/site/credential.go @@ -17,7 +17,7 @@ import ( // CredentialService is the walkthrough's service for name when adapter reads // it: false for a name the site setup does not read. func CredentialService(name string, adapter hosting.Adapter) (credential.Service, bool) { - if adapter == nil || name != adapter.CredentialName() { + if adapter == nil || adapter.CredentialName() == "" || name != adapter.CredentialName() { return credential.Service{}, false } return credential.Service{ @@ -48,11 +48,14 @@ func CredentialServiceFor(name string) (credential.Service, bool) { } // CredentialNames are the credentials the site setup reads, one per hosting -// provider. +// provider that reads one. func CredentialNames() []string { out := make([]string, 0, len(adapters)) for _, a := range adapters { - out = append(out, a.CredentialName()) + // A provider that acts through the forge reads no credential. + if a.CredentialName() != "" { + out = append(out, a.CredentialName()) + } } return out } diff --git a/internal/core/site/decisionlog.go b/internal/core/site/decisionlog.go index 4b95ebbb2..60860f7e0 100644 --- a/internal/core/site/decisionlog.go +++ b/internal/core/site/decisionlog.go @@ -71,7 +71,7 @@ func (e *explorer) decisionLogPage() (string, error) { if err != nil { return "", err } - l.WriteString(`<li><span class="id"><a href="/` + escapeAttr(RecordRoute(n)) + `">` + + l.WriteString(`<li><span class="id"><a href="` + escapeAttr(e.c.at("/"+RecordRoute(n))) + `">` + escapeText(n.ID) + `</a>`) if n.Date != "" { l.WriteString(`<span class="d">` + escapeText(n.Date) + `</span>`) diff --git a/internal/core/site/development.go b/internal/core/site/development.go index be962bcbb..818588c07 100644 --- a/internal/core/site/development.go +++ b/internal/core/site/development.go @@ -162,7 +162,7 @@ func (e *explorer) developmentStorePanel(nodes []ExportNode) string { // being the same length. What was cut is said at the foot of the list // instead, where a reader is already looking at the rows it cut, in the // same shape the health page states its own remainder. - deck := developmentDeck(shown) + deck := developmentDeck(shown, e.c.base) if rest := len(group) - len(shown); rest > 0 { deck += `<p class="small muted listrest"><b class="tnum">` + strconv.Itoa(rest) + `</b> ` + escapeText(e.c.ui.More) + `</p>` @@ -205,11 +205,11 @@ func sortDevelopmentNodes(nodes []ExportNode) { // browses; a store here holds hundreds, and a card grid of them is a wall to // scan rather than a list to read down. The id leads and the title follows, so // the column of ids reads as an index. -func developmentDeck(nodes []ExportNode) string { +func developmentDeck(nodes []ExportNode, base string) string { var b strings.Builder b.WriteString(`<ul class="list reclist">`) for _, n := range nodes { - b.WriteString(`<li><span class="id"><a href="/` + escapeAttr(RecordRoute(n)) + `">` + + b.WriteString(`<li><span class="id"><a href="` + escapeAttr(sitePath(base, "/"+RecordRoute(n))) + `">` + escapeText(n.ID) + `</a>`) if n.Date != "" { b.WriteString(`<span class="d">` + escapeText(n.Date) + `</span>`) diff --git a/internal/core/site/explorer.go b/internal/core/site/explorer.go index 1fa10fc10..4cf789da8 100644 --- a/internal/core/site/explorer.go +++ b/internal/core/site/explorer.go @@ -119,6 +119,12 @@ func newExplorer(c *composer, export RecordExport, bib *Bibliography, recordRoot glossaryByPath: map[string]glossaryEntry{}, pages: c.manifest.Pages.resolve(), } + // A project site has no timeline: it places most records at the day git + // first saw their file, and a project site reads no git history + // (project.go). The panel and its heading go together. + if c.project { + e.pages.timeline = false + } // 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. @@ -133,7 +139,7 @@ func newExplorer(c *composer, export RecordExport, bib *Bibliography, recordRoot for _, en := range entries { e.glossaryByPath[en.Path] = en } - e.terms = newTermLinker(entries) + e.terms = newTermLinker(entries, c.base) // A decision is a record with a page of its own and a link target like any // other; it is only the chart's node list it is kept out of. for _, n := range export.Decisions { @@ -185,6 +191,12 @@ func (e *explorer) hasFoundations() bool { return len(e.principles) > 0 || len(e.disciplines) > 0 } +// hasContributors reports whether the contributors page is built. A project +// site has none: its author names and declared tools are read out of git +// history, which is not something abcd writes to the repository, and a project +// site renders only what abcd writes (project.go). +func (e *explorer) hasContributors() bool { return !e.c.project } + // hasReferences reports whether the bibliography rendered. func (e *explorer) hasReferences() bool { return e.bib != nil && len(e.bib.Entries) > 0 } @@ -210,8 +222,10 @@ func (e *explorer) Pages() (map[string]string, error) { return nil, err } } - if err := add(routeContributors, e.contributorsPage); err != nil { - return nil, err + if e.hasContributors() { + if err := add(routeContributors, e.contributorsPage); err != nil { + return nil, err + } } if e.hasHealth() { if err := add(routeHealth, e.healthPage); err != nil { @@ -322,7 +336,9 @@ func (e *explorer) subnav(active string) string { if e.hasHealth() { tabs = append(tabs, tab{routeHealth, e.c.ui.RecordNav.Health}) } - tabs = append(tabs, tab{routeContributors, e.c.ui.RecordNav.Contributors}) + if e.hasContributors() { + tabs = append(tabs, tab{routeContributors, e.c.ui.RecordNav.Contributors}) + } if e.hasReferences() { tabs = append(tabs, tab{routeReferences, e.c.ui.NavReferences}) } @@ -333,7 +349,7 @@ func (e *explorer) subnav(active string) string { if t.route == active { cls, cur = ` class="on"`, ` aria-current="page"` } - b.WriteString(`<a href="/` + t.route + `"` + cls + cur + `>` + escapeText(t.label) + `</a>`) + b.WriteString(`<a href="` + escapeAttr(e.c.at("/"+t.route)) + `"` + cls + cur + `>` + escapeText(t.label) + `</a>`) } b.WriteString(`</div></nav>`) return b.String() @@ -508,7 +524,7 @@ func (e *explorer) dashboard() (string, error) { // The decision records are counted apart from the stores above: they are // not on the chart those tiles count for, and their one page is the log. if e.hasDecisionLog() { - b.WriteString(tileLinked("/"+routeDecisionLog, strconv.Itoa(len(e.export.Decisions)), + b.WriteString(tileLinked(e.c.at("/"+routeDecisionLog), strconv.Itoa(len(e.export.Decisions)), ui.RecordNav.DecisionLog, nil, "")) } @@ -548,11 +564,11 @@ func (e *explorer) tileHref(typ string) string { switch typ { case "principle": if e.hasFoundations() { - return "/" + routeFoundations + "#principle" + return e.c.at("/" + routeFoundations + "#principle") } case "adr", "intent", "spec", "issue": if e.hasDevelopment() { - return "/" + routeDevelopment + "#" + typ + return e.c.at("/" + routeDevelopment + "#" + typ) } } return "" @@ -627,7 +643,7 @@ func (e *explorer) latestDecisions() string { var b strings.Builder b.WriteString(`<ul class="list">`) for _, n := range adrs { - b.WriteString(`<li><span class="id"><a href="/` + escapeAttr(RecordRoute(n)) + `">` + escapeText(n.ID) + + b.WriteString(`<li><span class="id"><a href="` + escapeAttr(e.c.at("/"+RecordRoute(n))) + `">` + escapeText(n.ID) + `</a><span class="d">` + escapeText(n.Date) + `</span></span>` + `<span>` + escapeText(shortTitle(n)) + `</span></li>`) } @@ -645,8 +661,8 @@ func (e *explorer) health() string { // Two lines per finding: the fact, then its explanation — a flowing line // wrapped mid-phrase in the narrow panel. for _, u := range h.Unresolved { - b.WriteString(`<div class="hitem"><span class="hfact"><span class="w">!</span> <a href="/` + - escapeAttr(e.routeOf(u.From)) + `">` + escapeText(u.From) + `</a> → <b class="stub">` + + b.WriteString(`<div class="hitem"><span class="hfact"><span class="w">!</span> <a href="` + + escapeAttr(e.c.at("/"+e.routeOf(u.From))) + `">` + escapeText(u.From) + `</a> → <b class="stub">` + escapeText(u.To) + `</b></span><span class="hwhy">` + escapeText(relationWord(u.Rel)) + ` ` + escapeText(ui.Record.NotInTree) + `</span></div>`) } @@ -734,7 +750,7 @@ func (e *explorer) foundationsPage() (string, error) { var cards strings.Builder cards.WriteString(`<div class="fcards">`) for _, n := range nodes { - cards.WriteString(`<a class="fcard" href="/` + escapeAttr(RecordRoute(n)) + `">` + + cards.WriteString(`<a class="fcard" href="` + escapeAttr(e.c.at("/"+RecordRoute(n))) + `">` + `<span class="t">` + escapeText(shortTitle(n)) + `</span>` + `<span class="id">` + escapeText(n.ID) + `</span></a>`) } @@ -762,7 +778,7 @@ func (e *explorer) foundationsPage() (string, error) { // where a model name may appear, under the declared attribution escape // (adr-47 decision 3). func (e *explorer) contributorsPage() (string, error) { - a := e.export.Authorship + a := e.export.authorship() ui := e.c.ui var b strings.Builder b.WriteString(`<div class="dash">`) @@ -927,7 +943,7 @@ func (e *explorer) href(fromPath, target string) string { rel := path.Clean(path.Join(path.Dir(fromPath), file)) if strings.HasSuffix(file, ".md") { if n, ok := e.byPath[rel]; ok { - out := "/" + RecordRoute(n) + out := e.c.at("/" + RecordRoute(n)) if frag != "" { out += "#" + frag } @@ -938,13 +954,13 @@ func (e *explorer) href(fromPath, target string) string { // which is the one place a reader who followed a term link did not mean // to end up. if en, ok := e.glossaryByPath[rel]; ok { - out := "/" + en.Route + out := e.c.at("/" + en.Route) if frag != "" { out += "#" + frag } return out } - return siteHref(path.Dir(fromPath), target, e.c.repo.Repository, e.c.rendersDocs()) + return siteHref(path.Dir(fromPath), target, e.c.repo.Repository, e.c.rendersDocs(), e.c.base) } // A relative target that is NOT markdown — a directory, a script, a // configuration file. `siteHref` leaves those exactly as the record wrote diff --git a/internal/core/site/fixture_test.go b/internal/core/site/fixture_test.go index cde270c2d..62b10de29 100644 --- a/internal/core/site/fixture_test.go +++ b/internal/core/site/fixture_test.go @@ -349,7 +349,8 @@ func (f *fixture) writeSources() { "matches_system": "matches this computer", "read_script": "read the script", "unreleased": "unreleased", - "beta": "Beta" + "beta": "Beta", + "managed_record": "This is the record of an abcd-managed repository" } `) diff --git a/internal/core/site/forge.go b/internal/core/site/forge.go index 9cd1290ed..96fa6b76a 100644 --- a/internal/core/site/forge.go +++ b/internal/core/site/forge.go @@ -23,6 +23,8 @@ type Forge interface { // DefaultBranch reads the repository's default branch: the branch the // workflow gates on and the environments admit. DefaultBranch(ctx context.Context) (string, error) + // Private reads whether the repository is private. + Private(ctx context.Context) (bool, error) // 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. @@ -110,6 +112,20 @@ func (g *ghForge) DefaultBranch(context.Context) (string, error) { return doc.DefaultBranch, nil } +func (g *ghForge) Private(context.Context) (bool, error) { + out, err := g.api(nil, "", "repos/"+g.repo) + if err != nil { + return false, err + } + var doc struct { + Private *bool `json:"private"` + } + if err := json.Unmarshal(out, &doc); err != nil || doc.Private == nil { + return false, errors.New("the repository response does not say whether it is private") + } + return *doc.Private, nil +} + // perPage is the largest page the forge serves a list read in. const perPage = 100 diff --git a/internal/core/site/forge_pages_test.go b/internal/core/site/forge_pages_test.go new file mode 100644 index 000000000..498bd135b --- /dev/null +++ b/internal/core/site/forge_pages_test.go @@ -0,0 +1,51 @@ +package site + +// The github-pages provider's forge transport and the forge's visibility read +// (itd-2610091010348892), over in-process fakes of `gh api`. + +import ( + "context" + "reflect" + "testing" +) + +func TestTheForgeReadsWhetherTheRepositoryIsPrivate(t *testing.T) { + f := newFakeGHAPI() + for _, want := range []bool{true, false} { + f.objects["repos/"+f.repo] = map[string]any{"default_branch": "main", "private": want} + got, err := f.forge().Private(context.Background()) + if err != nil || got != want { + t.Fatalf("private = %v, %v; want %v", got, err, want) + } + } + f.objects["repos/"+f.repo] = map[string]any{"default_branch": "main"} + if _, err := f.forge().Private(context.Background()); err == nil { + t.Fatal("a repository response that does not say whether it is private was read as public") + } +} + +func TestThePagesProviderSpeaksThroughGhAsThePerson(t *testing.T) { + type call struct { + stdin string + args []string + } + var calls []call + api := ghPagesAPI(func(_ string, stdin []byte, args ...string) ([]byte, error) { + calls = append(calls, call{string(stdin), args}) + return []byte("{}"), nil + }) + if _, err := api(context.Background(), "GET", "repos/acme/widgets/pages", nil); err != nil { + t.Fatal(err) + } + if _, err := api(context.Background(), "POST", "repos/acme/widgets/pages", []byte(`{"build_type":"workflow"}`)); err != nil { + t.Fatal(err) + } + head := []string{"api", "--hostname", "github.com", "-H", "Accept: application/vnd.github+json"} + want := []call{ + {"", append(append([]string{}, head...), "repos/acme/widgets/pages")}, + {`{"build_type":"workflow"}`, append(append([]string{}, head...), "--method", "POST", "repos/acme/widgets/pages", "--input", "-")}, + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %q\nwant %q", calls, want) + } +} diff --git a/internal/core/site/glossary.go b/internal/core/site/glossary.go index bbc929ed2..62269fd73 100644 --- a/internal/core/site/glossary.go +++ b/internal/core/site/glossary.go @@ -115,7 +115,7 @@ func (e *explorer) glossaryIndexPage() (string, error) { var l strings.Builder l.WriteString(`<ul class="list reclist">`) for _, en := range entries { - l.WriteString(`<li><span class="id"><a href="/` + escapeAttr(en.Route) + `">` + + l.WriteString(`<li><span class="id"><a href="` + escapeAttr(e.c.at("/"+en.Route)) + `">` + escapeText(en.Term.Name) + `</a>`) if en.Term.Status != "" { l.WriteString(`<span class="d">` + escapeText(en.Term.Status) + `</span>`) @@ -204,14 +204,16 @@ type termLinker struct { // lengths are the distinct spelling lengths in bytes, longest first, so // "roadmap phase" is matched before the "phase" inside it. lengths []int + // base is the build's base path, which every term link sits under. + base string } // newTermLinker indexes the entries. A spelling two entries claim goes to the // first in the scan's own order, which is the directory's order: silently // preferring one is better than linking a word to two places, and the glossary's // own GL002 rule is what stops two terms claiming one word in the first place. -func newTermLinker(entries []glossaryEntry) *termLinker { - l := &termLinker{byName: map[string]string{}} +func newTermLinker(entries []glossaryEntry, base string) *termLinker { + l := &termLinker{byName: map[string]string{}, base: base} seen := map[int]bool{} add := func(name, route string) { name = strings.TrimSpace(name) @@ -402,8 +404,8 @@ func (l *termLinker) linkRun(b *strings.Builder, run string, linked map[string]b if n := l.matchAt(run, i, linked, skipRoute); n > 0 { match := run[i : i+n] route := l.byName[asciiLower(match)] - b.WriteString(`<a class="` + gtermClass + `" href="/` + - escapeAttr(route) + `">` + match + `</a>`) + b.WriteString(`<a class="` + gtermClass + `" href="` + + escapeAttr(sitePath(l.base, "/"+route)) + `">` + match + `</a>`) linked[route] = true i += n prevWord = true diff --git a/internal/core/site/glossary_test.go b/internal/core/site/glossary_test.go index 40bded32a..218544ef6 100644 --- a/internal/core/site/glossary_test.go +++ b/internal/core/site/glossary_test.go @@ -205,7 +205,7 @@ func TestOneLinkPerEntryPerPage(t *testing.T) { Term: glossary.Term{Name: "phase", Aliases: []string{"roadmap phase"}}, Route: "record/glossary/core/phase/", }, - }) + }, "/") got := l.link("<p>A spec is a specification, and an implementation spec too. "+ "A roadmap phase is a phase.</p>", "") want := `<p>A <a class="gterm" href="/record/glossary/core/spec/">spec</a> is a ` + diff --git a/internal/core/site/graphpage.go b/internal/core/site/graphpage.go index 204952e84..90fe7650d 100644 --- a/internal/core/site/graphpage.go +++ b/internal/core/site/graphpage.go @@ -23,7 +23,7 @@ func (e *explorer) graphPage() (string, error) { g := ui.Graph var b strings.Builder - b.WriteString(`<div class="bwrap" data-graph="/record.json"`) + b.WriteString(`<div class="bwrap" data-graph="` + escapeAttr(e.c.at("/record.json")) + `"`) b.WriteString(` data-linked="` + escapeAttr(g.Linked) + `"`) b.WriteString(` data-nolinks="` + escapeAttr(g.NoLinks) + `"`) b.WriteString(` data-mentions="` + escapeAttr(ui.Record.Mentions) + `"`) @@ -254,7 +254,7 @@ func containsStr(xs []string, x string) bool { // twinLink renders one relation in the list twin: the relation's word, and the // record it names as a link that focuses it in the chart. func (e *explorer) twinLink(word, id string) string { - return escapeText(word) + ` <a href="/` + escapeAttr(routeGraph) + `?focus=` + escapeAttr(id) + `">` + + return escapeText(word) + ` <a href="` + escapeAttr(e.c.at("/"+routeGraph)) + `?focus=` + escapeAttr(id) + `">` + escapeText(id) + `</a>` } @@ -275,7 +275,7 @@ func (e *explorer) listTwin() string { var b strings.Builder b.WriteString(`<ul class="nodelist">`) for _, n := range nodes { - b.WriteString(`<li><a class="id" href="/` + escapeAttr(RecordRoute(n)) + `">` + escapeText(n.ID) + `</a>`) + b.WriteString(`<li><a class="id" href="` + escapeAttr(e.c.at("/"+RecordRoute(n))) + `">` + escapeText(n.ID) + `</a>`) b.WriteString(`<span>` + escapeText(shortTitle(n)) + `</span>`) b.WriteString(`<span class="d">` + escapeText(n.Date) + `</span>`) // Each link is a LINK. The twin is the keyboard path through the chart, diff --git a/internal/core/site/health.go b/internal/core/site/health.go index f5a0ccd24..9e8447fbc 100644 --- a/internal/core/site/health.go +++ b/internal/core/site/health.go @@ -23,7 +23,8 @@ package site // // Generic side (itd-140): the inputs are the record format and git history. A // repository with no authorship data renders the families that do have data, and -// one with no findings at all renders the page saying so. +// one with no findings at all renders the page saying so. A project site reads +// no git history, so it renders the record's own families alone. import ( "strconv" @@ -41,7 +42,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 e.pages.status && (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. @@ -60,29 +61,42 @@ func (e *explorer) healthPage() (string, error) { // the answer. The lists below are folded, so the page opens as a summary. b.WriteString(`<div class="dash">`) ui := e.c.ui - a := e.export.Authorship - tiles := []struct { + a := e.export.authorship() + type count struct { n int label string - }{ + } + tiles := []count{ {len(e.export.Health.Unresolved), ui.Health.Unresolved}, {len(e.isolatedRecords()), ui.Health.Isolated}, - {len(e.sameAuthorCandidates()), ui.Health.SameAuthor}, - {a.Undeclared, ui.Health.Undeclared}, - {a.MultiTrailerCommits, ui.Health.MultiTrailer}, + } + // The authorship families are answered by git history, which a project + // site does not read (project.go): their tiles, the disclosure rate and + // their panels are left out there, and the checks the record makes of + // itself stay. + gitFamilies := !e.c.project + if gitFamilies { + tiles = append(tiles, + count{len(e.sameAuthorCandidates()), ui.Health.SameAuthor}, + count{a.Undeclared, ui.Health.Undeclared}, + count{a.MultiTrailerCommits, ui.Health.MultiTrailer}) } for _, t := range tiles { b.WriteString(tile(strconv.Itoa(t.n), t.label, nil)) } - b.WriteString(e.healthDisclosureRate()) + if gitFamilies { + b.WriteString(e.healthDisclosureRate()) + } b.WriteString(`</div>`) b.WriteString(`<div class="dash reading">`) b.WriteString(e.healthUnresolved()) b.WriteString(e.healthIsolated()) - b.WriteString(e.healthSameAuthor()) - b.WriteString(e.healthUndeclared()) - b.WriteString(e.healthMultiTrailer()) + if gitFamilies { + b.WriteString(e.healthSameAuthor()) + b.WriteString(e.healthUndeclared()) + b.WriteString(e.healthMultiTrailer()) + } b.WriteString(e.healthSupersedes()) b.WriteString(`</div>`) return e.shell(routeHealth, e.c.ui.RecordNav.Health, "", b.String()), nil @@ -100,7 +114,7 @@ func (e *explorer) healthPage() (string, error) { // commit has no rate, and renders no tile. func (e *explorer) healthDisclosureRate() string { ui := e.c.ui - a := e.export.Authorship + a := e.export.authorship() if a.Authored == 0 { return "" } @@ -153,8 +167,8 @@ func (e *explorer) healthUnresolved() string { var b strings.Builder b.WriteString(`<div class="health">`) for _, ed := range found { - b.WriteString(`<div class="hitem"><span class="hfact"><span class="w">!</span> <a href="/` + - escapeAttr(e.routeOf(ed.From)) + `">` + escapeText(ed.From) + `</a> → <b class="stub">` + + b.WriteString(`<div class="hitem"><span class="hfact"><span class="w">!</span> <a href="` + + escapeAttr(e.c.at("/"+e.routeOf(ed.From))) + `">` + escapeText(ed.From) + `</a> → <b class="stub">` + escapeText(ed.To) + `</b></span><span class="hwhy">` + escapeText(relationWord(ed.Rel)) + ` ` + escapeText(ui.Record.NotInTree) + `</span></div>`) } @@ -202,7 +216,7 @@ func (e *explorer) healthIsolated() string { var b strings.Builder b.WriteString(`<ul class="list">`) for _, n := range shown { - b.WriteString(`<li><span class="id"><a href="/` + escapeAttr(RecordRoute(n)) + `">` + + b.WriteString(`<li><span class="id"><a href="` + escapeAttr(e.c.at("/"+RecordRoute(n))) + `">` + escapeText(n.ID) + `</a></span><span>` + escapeText(shortTitle(n)) + `</span></li>`) } b.WriteString(`</ul>`) @@ -273,7 +287,7 @@ func nameIsProfileUser(name, profile string) bool { // address the suggested `.mailmap` line may name; where both do, the alias is // the row with fewer commits. func (e *explorer) sameAuthorCandidates() []authorPair { - humans := e.export.Authorship.Humans + humans := e.export.authorship().Humans var out []authorPair for i := range humans { for j := i + 1; j < len(humans); j++ { @@ -336,7 +350,7 @@ func (e *explorer) healthSameAuthor() string { // list whose length is the whole point of the number. The count is the finding. func (e *explorer) healthUndeclared() string { ui := e.c.ui - a := e.export.Authorship + a := e.export.authorship() if a.Undeclared == 0 { return panelDisclosure("c12", ui.Health.Undeclared, "", "0", e.healthClean()) } @@ -368,7 +382,7 @@ func (e *explorer) healthUndeclared() string { // of them. func (e *explorer) healthMultiTrailer() string { ui := e.c.ui - a := e.export.Authorship + a := e.export.authorship() // The COMMIT count decides whether the family has anything to report; the // trailer and commit totals below are the two figures that differ because // of it, and the separator between them goes the way the others did. diff --git a/internal/core/site/hosting.go b/internal/core/site/hosting.go index 7259a427e..7bc814a70 100644 --- a/internal/core/site/hosting.go +++ b/internal/core/site/hosting.go @@ -1,6 +1,10 @@ package site -import "regexp" +import ( + "regexp" + + "github.com/intentdriven/abcd/internal/adapter/hosting" +) // 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 @@ -11,8 +15,16 @@ type Hosting struct { Provider string `json:"provider"` Name string `json:"name"` Domain string `json:"domain,omitempty"` + // Publish is the project-site switch of a provider that publishes only + // while it is on (github-pages, itd-2610091010348892): setup asks for it + // and writes `"publish": true`, and the release workflow publishes nothing + // while it is anything else. A provider without a switch refuses it. + Publish *bool `json:"publish,omitempty"` } +// On reports whether the switch is on. +func (h *Hosting) On() bool { return h != nil && h.Publish != nil && *h.Publish } + // 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. @@ -31,9 +43,13 @@ func (h *Hosting) validate(bad func(string, ...any) error) error { if h.Provider == "" { return bad("hosting.provider is empty; it names the adapter setup routes the site through") } - if _, ok := adapterNamed(h.Provider); !ok { + a, ok := adapterNamed(h.Provider) + if !ok { return bad("hosting.provider %q is not a provider abcd ships (%s)", h.Provider, providerList()) } + if h.Publish != nil && !a.Profile(hosting.Site{}).Switched { + return bad("hosting.publish is the switch of a provider that publishes only while it is on; %s deploys every release and reads no switch", h.Provider) + } 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) } diff --git a/internal/core/site/managedline_test.go b/internal/core/site/managedline_test.go new file mode 100644 index 000000000..95281efd1 --- /dev/null +++ b/internal/core/site/managedline_test.go @@ -0,0 +1,85 @@ +package site + +// Every page opens with a line saying it is the record of an abcd-managed +// repository, linking to that repository (itd-2610091010348892). Its words are +// an interface string from the closed allowlist, so the provenance gate still +// refuses any text a repository file does not account for. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The words sit in a <small>, so a repository whose own site-src/site.css +// predates the line's `.managed` rule (setup keeps a stylesheet the repository +// already has) still shows it as a small line at the top of the page, not as a +// paragraph of body text. +func managedLine(href, words string) string { + return "<body>\n" + `<p class="managed"><small><a href="` + href + `">` + words + `</a></small></p>` +} + +func TestEveryPageOpensWithTheManagedRepositoryLine(t *testing.T) { + f := newFixture(t) + _, files := buildFixtureAt(t, f, "") + ui, err := LoadUI(f.Root(), "site-src/ui.json") + if err != nil { + t.Fatal(err) + } + if ui.ManagedRecord == "" { + t.Fatal("the allowlist declares no words for the line") + } + want := managedLine("https://example.invalid/fixture/repo", escapeText(ui.ManagedRecord)) + pages := 0 + for rel, data := range files { + if !strings.HasSuffix(rel, ".html") { + continue + } + pages++ + if !strings.Contains(string(data), want) { + t.Errorf("%s does not open with the managed-repository line %q", rel, want) + } + } + if pages < 5 { + t.Fatalf("only %d pages were built", pages) + } +} + +// A managed repository need not carry a package manifest. Its repository is +// then the origin remote on github.com, which is what a release workflow's +// checkout carries, so the line still links it. +func TestTheLineLinksTheOriginWithoutAPackageManifest(t *testing.T) { + f := newFixture(t) + if err := os.Remove(filepath.Join(f.Root(), ".claude-plugin", "plugin.json")); err != nil { + t.Fatal(err) + } + f.gitOut("remote", "add", "origin", "git@github.com:acme/widgets.git") + meta, err := LoadRepoMeta(f.Root()) + if err != nil { + t.Fatal(err) + } + if meta.Repository != "https://github.com/acme/widgets" { + t.Fatalf("repository = %q, want the origin's github.com address", meta.Repository) + } +} + +// With no repository address at all the line still says what the site is. +func TestTheLineStandsWithoutARepositoryAddress(t *testing.T) { + c := &composer{ui: UI{ManagedRecord: "The record."}} + if got := c.managedLine(); got != `<p class="managed"><small>The record.</small></p>` { + t.Fatalf("line = %q", got) + } +} + +// The seed stylesheet's rule sizes the line itself, so the <small> inside it +// takes the rule's size rather than shrinking the words a second time. +func TestTheSeedStylesheetSizesTheLineOnce(t *testing.T) { + css, err := setupSources.ReadFile("setupsrc/site.css") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(css), ".managed small{font-size:inherit}") { + t.Fatal("the seed stylesheet does not size the managed line's <small> as the line") + } +} diff --git a/internal/core/site/manifest.go b/internal/core/site/manifest.go index 41d6df989..aa4d8a8c8 100644 --- a/internal/core/site/manifest.go +++ b/internal/core/site/manifest.go @@ -196,6 +196,14 @@ const ( // the person who wrote it, from a key that works. type Manifest struct { SchemaVersion int `json:"schema_version"` + // Mode says which route the file declares. Absent, it is a composition: + // every block below names where the site's text comes from. ModeProject + // makes it a project site's SETTINGS (itd-2610091010348892, the product + // thinker's ruling of 2026-10-15): the hosting block and the switch, and + // no composition at all, because a project site renders only what abcd + // already writes to the repository (project.go). validate refuses every + // composition key in a settings file, so the two shapes never mix. + Mode string `json:"mode,omitempty"` // Purpose documents this file for the humans who edit it. It is the one // field with nothing to consume it, by design: it is never rendered, and // the single-source rule would forbid rendering it if it were. @@ -369,6 +377,13 @@ func (m Manifest) validate() error { if m.SchemaVersion != 1 { return bad("schema_version is %d, want 1", m.SchemaVersion) } + switch m.Mode { + case "": + case ModeProject: + return m.validateProject(bad) + default: + return bad("mode %q is not a route abcd builds (omit it for a composition, or %q for a project site's settings)", m.Mode, ModeProject) + } if m.Identity.File == "" || m.Identity.Heading == "" { return bad("identity.file and identity.heading are both required — the hero renders from the Identity block") } diff --git a/internal/core/site/markdown_test.go b/internal/core/site/markdown_test.go index f4e0b9447..07db175b9 100644 --- a/internal/core/site/markdown_test.go +++ b/internal/core/site/markdown_test.go @@ -300,7 +300,7 @@ func TestRenderRefusesOutOfSubset(t *testing.T) { // enforceable at render time rather than at review time. func TestRenderRefusesRemoteImage(t *testing.T) { dir := t.TempDir() - pipe := newAssetPipe(dir, mustOpenRoot(t, dir)) + pipe := newAssetPipe(dir, mustOpenRoot(t, dir), "/") r := testRenderer() r.Image = func(src, alt string, at Source) (string, error) { return pipe.render("docs", src, alt, at) } _, err := r.RenderBlocks("docs/page.md", Blocks("![alt](https://example.invalid/x.png)", 1)) @@ -478,18 +478,18 @@ func TestSiteHref(t *testing.T) { forge + "/blob/HEAD/.abcd/development/decisions/adrs/0047-x.md#decision"}, } for _, c := range cases { - if got := siteHref(c.dir, c.in, forge, true); got != c.want { + if got := siteHref(c.dir, c.in, forge, true, "/"); got != c.want { t.Errorf("siteHref(%q, %q) = %q, want %q", c.dir, c.in, got, c.want) } } // With no forge URL there is nothing to point at, and the record's own text // is left exactly as written. - if got := siteHref("docs/explanation", "../../CONTRIBUTING.md", "", true); got != "../../CONTRIBUTING.md" { + if got := siteHref("docs/explanation", "../../CONTRIBUTING.md", "", true, "/"); got != "../../CONTRIBUTING.md" { t.Errorf("siteHref with no forge = %q, want the href unchanged", got) } // A site with no docs tree has no docs route to send a docs page to, so // it goes to the forge's view like any other file. - if got := siteHref("docs/explanation", "roles.md#x", forge, false); got != forge+"/blob/HEAD/docs/explanation/roles.md#x" { + if got := siteHref("docs/explanation", "roles.md#x", forge, false, "/"); got != forge+"/blob/HEAD/docs/explanation/roles.md#x" { t.Errorf("siteHref with no docs tree = %q, want the forge's view of the page", got) } } diff --git a/internal/core/site/project.go b/internal/core/site/project.go new file mode 100644 index 000000000..76ae7991c --- /dev/null +++ b/internal/core/site/project.go @@ -0,0 +1,538 @@ +package site + +// The project route (itd-2610091010348892): a project site built from nothing +// but what abcd already writes to the repository. +// +// The product thinker ruled on 2026-10-15 that a project's site renders only +// what abcd already writes to the repository, as its single source, and +// nothing written for the website. A project's hand-written documentation is +// read on GitHub, not rendered. So the project route has no +// composition: `.abcd/site.json` holds the project site's SETTINGS (the hosting +// block and its switch, `"mode": "project"`), and nothing in it says where a +// word comes from. The pages are derived in code: +// +// - the about page (`/`), from the identity block, the brief's opening, the +// newest shipped intents' press releases and the release list in +// CHANGELOG.md, each rendered only where the repository holds it; +// - the record explorer, as the composed route renders it, less everything +// read out of git history rather than written by abcd: the contributors +// page (author names and declared tools), the timeline (records placed at +// the day git first saw them), the health page's authorship checks, and +// record.json's authorship, history and per-record git dates. The +// references page, which reads ACKNOWLEDGEMENTS.md, is left out too. A +// project build never walks git history (no log, no shortlog, no blame): +// it asks git only for the origin remote, which names the repository, the +// commit it builds, and the root commit that marks its output directory; +// - the interface words, the stylesheet, the scripts and the headers policy, +// from the binary (setupsrc/), never from files in the repository. +// +// The composed route (a `.abcd/site.json` with no mode: abcd's own site) is +// untouched by any of this. `abcd site build --project` and `abcd lint site +// --project` name the route, and each refuses a settings file of the other +// shape, so neither route is ever entered by inference. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/lint" + "github.com/intentdriven/abcd/internal/core/positioning" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// ModeProject is the settings file's mode for a project site. +const ModeProject = "project" + +// Project reports whether the file declares a project site's settings rather +// than a composition. +func (m Manifest) Project() bool { return m.Mode == ModeProject } + +// projectSettingsPurpose documents a project site's settings file for the +// people who read it. Like a composition's purpose, it is never rendered. +const projectSettingsPurpose = "This repository's project-site settings, written by `abcd site setup`. " + + "The site renders only what abcd already writes to the repository (the identity block, the brief, " + + "the development record and CHANGELOG.md), so this file holds settings and no content: `hosting` " + + "names where the site is published, and `hosting.publish` is the switch." + +// projectSettings is the settings file as setup writes it, in this key order. +type projectSettings struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Purpose string `json:"purpose"` + Hosting *Hosting `json:"hosting"` +} + +// encodeProjectSettings renders a project site's settings file. +func encodeProjectSettings(h *Hosting) ([]byte, error) { + raw, err := json.MarshalIndent(projectSettings{SchemaVersion: 1, Mode: ModeProject, Purpose: projectSettingsPurpose, Hosting: h}, "", " ") + if err != nil { + return nil, err + } + return append(raw, '\n'), nil +} + +// validateProject refuses a settings file that carries anything but settings. +// A composition key here would be a sentence's source written for the website, +// which is the thing the project route exists not to have. +func (m Manifest) validateProject(bad func(string, ...any) error) error { + for _, f := range []struct { + key string + set bool + }{ + {"identity", m.Identity != (BlockRef{})}, + {"ui_strings", m.UIStrings != ""}, + {"home", m.Home.Hero != (Hero{}) || len(m.Home.Chapters) > 0}, + {"docs", m.Docs != (DocsRefs{})}, + {"record_pages", m.RecordPages != (RecordPages{})}, + {"checks", m.Checks != (ManifestGate{})}, + } { + if f.set { + return bad("%s is a composition key, and a project site's settings carry none: its pages are built from what abcd writes to the repository", f.key) + } + } + if m.Hosting == nil { + return bad("a project site's settings need a hosting block: it names the provider and holds the switch") + } + if err := m.Hosting.validate(bad); err != nil { + return err + } + if a, _ := adapterNamed(m.Hosting.Provider); !a.Profile(hosting.Site{}).Project { + return bad("hosting.provider %q publishes a composed site, not a project site", m.Hosting.Provider) + } + return m.Pages.validate(bad) +} + +// abcdWrittenSource reports whether rel is a file abcd itself writes to a +// repository, and so a file a project site may render a span of: +// +// - `.abcd/**`, the record abcd keeps: the brief, intents, specs, ADRs and +// principles under `.abcd/development/`, the issue ledger under +// `.abcd/work/issues/`, and the identity block `abcd identity init` writes +// to `.abcd/development/IDENTITY.md` (positioning.DefaultBlockLocation). +// The gitignored local tier, `.abcd/.work.local/`, is no one's published +// record and is refused. +// - `CHANGELOG.md`, which abcd derives: the launch scaffold seeds it +// (scaffold.ChangelogAnchor), and `abcd launch ship` writes each dated +// section from the records that shipped (release.Ingest). +// +// Everything else, `docs/` and `README.md` above all, is a person's writing, +// read on the forge where it lives. +func abcdWrittenSource(rel string) bool { + if rel == changelogFile { + return true + } + return strings.HasPrefix(rel, ".abcd/") && !strings.HasPrefix(rel, ".abcd/.work.local/") +} + +// routeAgrees refuses a build or a gate whose --project flag and settings file +// name different routes: the route is declared, never inferred. +func routeAgrees(m Manifest, project bool) error { + switch { + case m.Project() && !project: + return fmt.Errorf("site: %s holds a project site's settings, built from what abcd writes; pass --project", ManifestRelPath) + case !m.Project() && project: + return fmt.Errorf("site: --project builds a project site, and %s is a composition; build it without --project", ManifestRelPath) + } + return nil +} + +// projectUI is the interface-string allowlist the binary carries: a project +// site's interface words come from abcd, never from a file in the repository. +func projectUI() (UI, error) { + data, err := setupSources.ReadFile("setupsrc/ui.json") + if err != nil { + return UI{}, err + } + return decodeUI(data, "the binary's ui.json") +} + +// projectCopies are the static files a project site is served with, from the +// binary: the headers policy, the stylesheet and the two scripts. There is no +// redirect map (that is abcd's own URL history) and no installer. +var projectCopies = []struct{ src, dst string }{ + {"headers", "_headers"}, + {"site.css", "site.css"}, + {"site.js", "site.js"}, + {"record.js", "record.js"}, +} + +// BriefOpeningRelPath is where the brief opens: the first section of abcd's +// brief skeleton, `product/press-release`, laid out at +// `.abcd/development/brief/01-product/01-press-release.md` (the brief↔lifeboat +// contract, internal/core/lifeboat/mapping.go, and the native layout every +// managed repository's brief follows, lifeboat's nativeBriefFilePath). +const BriefOpeningRelPath = ".abcd/development/brief/01-product/01-press-release.md" + +// aboutShippedIntents is how many shipped intents the about page quotes. +const aboutShippedIntents = 3 + +// ErrProjectEmpty refuses a project site with nothing to build its about page +// from: setup does not switch it on, and a build does not write it. +var ErrProjectEmpty = errors.New("site: this repository holds nothing abcd writes that a project site is built from yet: " + + "no identity block (`abcd identity init`), no brief opening (" + BriefOpeningRelPath + "), no shipped intent whose " + + "audit was met, and no release in CHANGELOG.md; the site would be empty, so it is neither switched on nor built") + +// aboutSources is what the about page is built from. Each part is set only +// where the repository holds it: anything absent is left out, never filled. +type aboutSources struct { + identity bool + brief *docPage + opening int + intents []lint.RecordNode + releases []changelog.DatedRelease +} + +// empty reports whether the about page would have nothing on it. +func (a aboutSources) empty() bool { + return !a.identity && a.brief == nil && len(a.intents) == 0 && len(a.releases) == 0 +} + +// projectIdentity resolves the identity block a project site renders: the one +// `.abcd/positioning.json` points at, when abcd wrote the file it lives in. +// One pointed at a person's own file (a README, a docs page) is absent here. +func projectIdentity(root string) (BlockRef, error) { + cfg, ok, err := positioning.LoadConfig(root) + if err != nil { + return BlockRef{}, fmt.Errorf("site: the identity pointer cannot be read: %w", err) + } + if !ok || !fsutil.ValidRelPath(cfg.Block.File) || !abcdWrittenSource(cfg.Block.File) { + return BlockRef{}, nil + } + return BlockRef{File: cfg.Block.File, Heading: cfg.Block.Heading}, nil +} + +// aboutSources finds what the about page is built from. +func (c *composer) aboutSources() (aboutSources, error) { + var a aboutSources + _, a.identity = c.identity() + p, i, err := c.briefOpening() + if err != nil { + return aboutSources{}, err + } + a.brief, a.opening = p, i + if met := c.metIntents(); len(met) > aboutShippedIntents { + a.intents = met[:aboutShippedIntents] + } else { + a.intents = met + } + releases, _, err := changelog.DatedReleases(c.repoRoot) + if err != nil { + return aboutSources{}, err + } + a.releases = releases + return a, nil +} + +// briefOpening reads the brief's opening: its first section with anything in +// it. A brief that does not open there, or opens on nothing, is absent. +func (c *composer) briefOpening() (*docPage, int, error) { + data, err := fsutil.ReadGuardedInRoot(c.root, BriefOpeningRelPath, maxPageBytes) + if os.IsNotExist(err) { + return nil, 0, nil + } + if err != nil { + return nil, 0, fmt.Errorf("site: reading the brief's opening %s: %w", BriefOpeningRelPath, err) + } + body, consumed := StripFrontmatter(string(data)) + secs, err := Sections(BriefOpeningRelPath, body, consumed) + if err != nil { + return nil, 0, err + } + for i, s := range secs { + if len(Blocks(s.Body, s.BodyLine)) > 0 { + return &docPage{Rel: BriefOpeningRelPath, Dir: path.Dir(BriefOpeningRelPath), Sections: secs}, i, nil + } + } + return nil, 0, nil +} + +// recordLintRelPath is the record's lint configuration, which names the +// record's roots and the directory of each record store. +const recordLintRelPath = ".abcd/record-lint.json" + +// standardRecordRoot is where abcd's standard layout keeps the durable record. +const standardRecordRoot = ".abcd/development" + +// loadRecordConfig is the one place a build reads where the record lives. +// The record's lint configuration says so where the repository carries one. +// A managed repository need not: nothing abcd writes for it creates the file, +// so on the project route an absent one reads as abcd's standard layout +// (standardRecordConfig). On the composed route an absent one stays absent, as +// it always has. +func loadRecordConfig(root *os.Root, project bool) (lint.Config, error) { + cfg, err := lint.LoadConfigInRoot(root, recordLintRelPath) + switch { + case err == nil: + return cfg, nil + case !os.IsNotExist(err): + return lint.Config{}, err + case project: + return standardRecordConfig(), nil + } + return lint.Config{}, nil +} + +// standardRecordConfig is abcd's standard layout: the design record under +// `.abcd/development/`, its intents, specs, ADRs and principles (the stores the +// record explorer renders), and the issue ledger under `.abcd/work/issues`. +// The ledger is always read: an intent cites an issue by a typed field +// (`refines:` and `duplicates:`, which the filing-time match writes), and an +// unread ledger would leave each such citation unresolved. Reading it is not +// publishing it: the ledger is working-tier data (adr-32), and the record +// export leaves every issue out unless the settings opt in +// (RecordOpts.IssueLedger). +func standardRecordConfig() lint.Config { + stores := map[string]string{ + "adr": recordid.ADRsRelDir, + "itd": intent.IntentsRelDir, + "spc": standardRecordRoot + "/specs", + "iss": recordid.IssuesRelDir, + "prn": path.Join(standardRecordRoot, principlesDirName), + } + return lint.Config{ + Roots: []string{standardRecordRoot}, + // The record_schema rule's stores: the one key LoadRecordGraph reads. + Rules: map[string]lint.RuleConfig{"record_schema": {RecordStores: stores}}, + } +} + +// projectHasContent reports whether the repository at root holds anything a +// project site's about page is built from. Setup asks it before switching a +// project site on. +func projectHasContent(root string) (bool, error) { + srcRoot, err := os.OpenRoot(root) + if err != nil { + return false, err + } + defer srcRoot.Close() + id, err := projectIdentity(root) + if err != nil { + return false, err + } + cfg, err := loadRecordConfig(srcRoot, true) + if err != nil { + return false, err + } + graph, err := lint.LoadRecordGraph(cfg, root) + if err != nil { + return false, err + } + c := &composer{repoRoot: root, root: srcRoot, identityLoc: id, graph: graph} + a, err := c.aboutSources() + if err != nil { + return false, err + } + return !a.empty(), nil +} + +// ComposeAbout renders a project site's about page: the identity block as the +// hero, then the brief's opening, the newest shipped intents' press releases +// and the release list, each only where the repository holds it. +func (c *composer) ComposeAbout() (string, error) { + a, err := c.aboutSources() + if err != nil { + return "", err + } + var b strings.Builder + b.WriteString(c.head()) + b.WriteString(c.header()) + b.WriteString(`<main id="app"><div class="page">`) + switch { + case a.identity: + b.WriteString(c.aboutHero()) + case c.repo.Name != "": + // No identity block: the page is headed by the repository's name on + // GitHub, from the origin remote (projectRepoMeta), which is the + // forge's name for it rather than words written for the website. + b.WriteString(`<div class="wrap rechead"><h1 class="pagetitle">` + escapeText(c.repo.Name) + `</h1></div>`) + } + if a.brief != nil { + h, err := c.aboutBrief(a.brief, a.opening) + if err != nil { + return "", err + } + b.WriteString(h) + } + if len(a.intents) > 0 { + var quotes strings.Builder + for _, n := range a.intents { + h, err := c.featureBlockFor(n, &Feature{Kind: featureShippedPR, Pick: featurePickNewest, Parts: []string{featurePartPR}}) + if err != nil { + return "", err + } + quotes.WriteString(h) + } + if quotes.Len() > 0 { + b.WriteString(aboutSection("shipped", `<p class="eyebrow">`+escapeText(c.ui.Tiles.Intent)+`</p>`, quotes.String())) + } + } + if len(a.releases) > 0 { + h, err := c.aboutReleases(a.releases) + if err != nil { + return "", err + } + b.WriteString(h) + } + b.WriteString("</div></main>") + b.WriteString(c.footer()) + b.WriteString("</body>\n</html>\n") + return b.String(), nil +} + +// aboutSection wraps one part of the about page. The composed route's chapter +// head sets a letter beside its heading, and a project site has no letters, so +// the heading sits in the column on its own. +func aboutSection(id, head, body string) string { + return `<section class="chapter" id="` + escapeAttr(id) + `"><div class="wrap">` + head + + `<div class="stack" style="--stack:22px;margin-top:28px">` + body + `</div></div></section>` +} + +// aboutHero renders the identity block: its title, its tagline as the page's +// heading and its pitch beside them. +func (c *composer) aboutHero() string { + blk, _ := c.identity() + src := c.identitySrc() + var b strings.Builder + b.WriteString(`<section class="hero"><div class="wrap"><div class="grid"><div>`) + b.WriteString(`<p class="eyebrow"` + src + `>` + escapeText(blk.Title) + `</p>`) + b.WriteString(`<h1 class="tagline"` + src + `>` + escapeText(blk.Tagline) + `</h1>`) + b.WriteString(`</div>`) + if blk.Pitch != "" { + b.WriteString(`<div class="hero-aside"><p class="pitch"` + src + `>` + escapeText(blk.Pitch) + `</p></div>`) + } + b.WriteString(`</div></div></section>`) + return b.String() +} + +// aboutBrief renders the brief's opening section, verbatim. +func (c *composer) aboutBrief(p *docPage, i int) (string, error) { + s := p.Sections[i] + body, err := c.renderer(p).RenderBlocks(p.Rel, Blocks(s.Body, s.BodyLine)) + if err != nil { + return "", err + } + head := "" + if s.Title != "" { + head = `<h2` + srcAttr(p.Rel, s.Anchor) + `>` + escapeText(s.Title) + `</h2>` + } + id := s.Anchor + if id == "" { + id = "brief" + } + return aboutSection(id, head, `<div class="prose"`+srcAttr(p.Rel, s.Anchor)+`>`+body+`</div>`), nil +} + +// aboutReleases renders the release list: each dated release in CHANGELOG.md, +// newest first, with the date it carries, linked to its release on the forge. +func (c *composer) aboutReleases(releases []changelog.DatedRelease) (string, error) { + anchors := map[string]string{} + data, err := fsutil.ReadGuardedInRoot(c.root, changelogFile, changelog.MaxChangelogBytes) + if err != nil { + return "", err + } + body, consumed := StripFrontmatter(string(data)) + secs, err := Sections(changelogFile, body, consumed) + if err != nil { + return "", err + } + for _, s := range secs { + if v, _, ok := strings.Cut(strings.TrimPrefix(s.Title, "["), "]"); ok && strings.HasPrefix(s.Title, "[") { + if _, seen := anchors[v]; !seen { + anchors[v] = s.Anchor + } + } + } + var items strings.Builder + items.WriteString(`<ul class="stack" style="--stack:6px;list-style:none;padding:0">`) + for _, r := range releases { + version := escapeText("v" + strings.TrimPrefix(r.Version, "v")) + if c.repo.Repository != "" { + version = `<a href="` + escapeAttr(c.repo.Repository+"/releases/tag/v"+strings.TrimPrefix(r.Version, "v")) + `">` + version + `</a>` + } + line := version + if r.Date != "" { + line += ` · ` + escapeText(r.Date) + } + items.WriteString(`<li` + srcAttr(changelogFile, anchors[r.Version]) + `>` + line + `</li>`) + } + items.WriteString(`</ul>`) + if c.repo.Repository != "" { + rr := c.repo.Repository + items.WriteString(`<p class="small muted"><a href="` + escapeAttr(rr+"/releases") + `">` + escapeText(c.ui.AllReleases) + `</a> · ` + + `<a href="` + escapeAttr(forgeBlob(rr, changelogFile)) + `">` + escapeText(changelogFile) + `</a></p>`) + } + return aboutSection("releases", `<p class="eyebrow">`+escapeText(c.ui.Tiles.Releases)+`</p>`, items.String()), nil +} + +// metIntents are the shipped intents the record shows delivered: their audit +// was met and their press release was written, newest id first. +// +// The order reads no git history, which a project site does not show: the date +// an intent entered shipped/ is git's, not abcd's. The id is abcd's. A +// timestamped id, `itd-<yymmddHHMMSS><rrrr>`, carries the moment abcd minted +// it, so its number orders it in time; an ordinal id, `itd-N`, was minted +// before every timestamped one and sorts below them all, by N. That is +// lint.HandleLess's order, read backwards. +func (c *composer) metIntents() []lint.RecordNode { + var out []lint.RecordNode + for _, n := range c.graph.Nodes { + if n.Type != "intent" || n.Lifecycle != "shipped" { + continue + } + if !c.auditIsMet(n.Path) || c.pressReleaseIsUnwritten(n.Path) { + continue + } + out = append(out, n) + } + sort.SliceStable(out, func(i, j int) bool { return lint.HandleLess(out[j].ID, out[i].ID) }) + return out +} + +// checkProjectTitle refuses a project site with no title. The composer titles +// every page, and sets the brand, from the identity block's title, or failing +// that from the repository's name on its GitHub origin (headWith). With +// neither, every `<title>` is empty and the brand a link with no words, and +// the provenance walk passes both, because an empty string carries no word to +// trace. The composed route is not held to this: its manifest declares its +// identity. +func (c *checker) checkProjectTitle() { + title := c.repo.Name + if c.hasBlock { + title = c.identity.Title + } + if strings.TrimSpace(title) != "" { + return + } + c.fail(CheckProvenance, homePageRel, "", + "the site has no title: this repository records no identity block abcd wrote and has no GitHub origin to name it; "+ + "record an identity block (abcd identity init), or add a GitHub origin remote") +} + +// checkProjectSources is the project route's half of the provenance gate: +// every span on every page, the record's own pages included, names a file abcd +// writes. A span from `docs/` or `README.md` is a person's writing, which a +// project site does not render (the product thinker's ruling of 2026-10-15). +func (c *checker) checkProjectSources() { + for _, page := range c.res.Pages { + seen := map[string]bool{} + for _, el := range findElements(c.pages[page], func(n *htmlNode) bool { return n.Attr("data-src") != "" }) { + src := el.Attr("data-src") + rel, _, _ := strings.Cut(src, "#") + if seen[rel] || abcdWrittenSource(rel) { + continue + } + seen[rel] = true + c.fail(CheckProvenance, page, src, "a span comes from %s, which abcd does not write; a project site renders only what abcd writes "+ + "(.abcd/** and CHANGELOG.md), and a person's own files are read on the forge", quote(rel)) + } + } +} diff --git a/internal/core/site/project_history_test.go b/internal/core/site/project_history_test.go new file mode 100644 index 000000000..98fbef68a --- /dev/null +++ b/internal/core/site/project_history_test.go @@ -0,0 +1,275 @@ +package site + +// A project site shows nothing read from git history: no author, no model a +// commit declared, no commit count and no commit date. Git history is not +// something abcd writes to a repository, and a project site renders only what +// abcd writes (project.go). The composed route, abcd's own site, keeps all of +// it. + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// The project history fixture's distinctive git facts: an author, the same +// person under a second name (a same-author candidate pair on the health +// page), a declared model, and two commit dates nothing else in the fixture +// carries. None of them may reach a project site. +const ( + historyAuthor = "Zebulon Quixwright" + historyAuthorAlias = "zquix" + historyNoreply = "4242+zquix@users.noreply.github.com" + historyModel = "Quixvendor:quix-model-9" + historyDateOlder = "2031-07-19" + historyDateNewer = "2031-07-20" +) + +// Two met shipped intents with timestamped ids. The OLDER id is shipped in the +// NEWER commit, so an order by the date each entered shipped/ and an order by +// id disagree. +const ( + newerIDIntent = "itd-2602010900002222" + olderIDIntent = "itd-2601010900001111" + newerIDQuote = "The newer id is quoted first." + olderIDQuote = "The older id is quoted second." +) + +// metIntentBody is a shipped intent whose audit was met and whose press +// release is written. +func metIntentBody(id, slug, title, quote string) string { + return "---\nid: " + id + "\nslug: " + slug + "\nspec_id: null\nkind: standalone\nbuilds_on: []\n" + + "severity: minor\nimpact: additive\n---\n\n# " + title + "\n\n## Press Release\n\n> **" + quote + "**\n\n" + + "## Acceptance Criteria\n\n- Given the intent, then it is met.\n\n## Audit Notes\n\n" + + "Acceptance rollup: MET 1 · MET_WITH_CONCERNS 0 · NOT_MET 0 · INCONCLUSIVE 0\n" +} + +// commitAs stages everything and commits it under a named author at a fixed +// date, with an attribution trailer. +func (f *fixture) commitAs(date, name, email, subject, assistedBy string) { + f.t.Helper() + f.git(date, "add", "-A") + cmd := exec.Command("git", "-C", f.root, "-c", "commit.gpgsign=false", + "commit", "--allow-empty", "-m", subject+"\n\nAssisted-by: "+assistedBy) + cmd.Env = append(gittest.Env(f.t), + "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date, + "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email, + "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email) + if out, err := cmd.CombinedOutput(); err != nil { + f.t.Fatalf("git commit: %v\n%s", err, out) + } +} + +// newProjectHistoryFixture is the project fixture with the distinctive history +// above: two met intents with timestamped ids, each written and shipped by the +// fixture's distinctive author on a date nothing else carries. +func newProjectHistoryFixture(t *testing.T) *fixture { + t.Helper() + f := newProjectFixture(t) + shipped := ".abcd/development/intents/shipped/" + f.write(shipped+newerIDIntent+"-newer-id.md", metIntentBody(newerIDIntent, "newer-id", "The Newer Id", newerIDQuote)) + f.commitAs(historyDateOlder+"T09:00:00+00:00", historyAuthor, historyNoreply, "feat: ship the newer id", historyModel) + f.write(shipped+olderIDIntent+"-older-id.md", metIntentBody(olderIDIntent, "older-id", "The Older Id", olderIDQuote)) + f.commitAs(historyDateNewer+"T09:00:00+00:00", historyAuthorAlias, "zquix@example.invalid", "feat: ship the older id", historyModel) + return f +} + +// Every page and record.json on the project route are free of git history: +// no author name, no declared model, no commit date, and no record.json key +// that carries one. +func TestAProjectSiteShowsNothingFromGitHistory(t *testing.T) { + f := newProjectHistoryFixture(t) + out, files := buildProject(t, f) + if len(files) == 0 { + t.Fatal("the project build wrote nothing") + } + for rel, data := range files { + for _, word := range []string{historyAuthor, historyAuthorAlias, "quix-model-9", "Quixvendor", historyDateOlder, historyDateNewer} { + if strings.Contains(string(data), word) { + t.Errorf("%s carries %q, which is read from git history", rel, word) + } + } + } + + var export map[string]json.RawMessage + if err := json.Unmarshal(files["record.json"], &export); err != nil { + t.Fatal(err) + } + for _, key := range []string{"authorship", "history"} { + if _, ok := export[key]; ok { + t.Errorf("record.json carries %q, which is read from git history", key) + } + } + var nodes struct { + Nodes []map[string]json.RawMessage `json:"nodes"` + } + if err := json.Unmarshal(files["record.json"], &nodes); err != nil { + t.Fatal(err) + } + for _, n := range nodes.Nodes { + if _, ok := n["dates"]; ok { + t.Errorf("record.json node %s carries git dates", n["id"]) + break + } + } + + // The health page keeps the checks the record makes of itself and drops + // the ones git history answers; the timeline, drawn mostly from git dates, + // is left out with its link. + ui, err := projectUI() + if err != nil { + t.Fatal(err) + } + health := string(files[routeHealth+"index.html"]) + if health == "" { + t.Fatal("the project site has no health page") + } + for _, label := range []string{ui.Health.SameAuthor, ui.Health.Undeclared, ui.Health.MultiTrailer, ui.Contributors.Assisted} { + if strings.Contains(health, escapeText(label)) { + t.Errorf("the health page carries %q, a check git history answers", label) + } + } + for _, label := range []string{ui.Health.Unresolved, ui.Health.Isolated} { + if !strings.Contains(health, escapeText(label)) { + t.Errorf("the health page lost %q, a check the record makes of itself", label) + } + } + for rel, data := range files { + if strings.Contains(string(data), `class="tlsvg"`) || strings.Contains(string(data), ">"+escapeText(ui.RecordNav.Timeline)+"<") { + t.Errorf("%s carries the timeline, which is drawn from git dates", rel) + } + } + + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + for _, fd := range res.Findings { + t.Errorf("finding: %+v", fd) + } + } +} + +// The about page quotes the newest shipped intents in id order: a timestamped +// id `itd-<yymmddHHMMSS><rrrr>` carries when it was minted, and an ordinal id +// sorts before every timestamped one. The date each entered shipped/ is git's, +// and plays no part. +func TestTheAboutPageOrdersShippedIntentsByID(t *testing.T) { + _, files := buildProject(t, newProjectHistoryFixture(t)) + about := string(files["index.html"]) + order := []string{newerIDQuote, olderIDQuote, "A fixture user opens the page and sees the record."} + last := -1 + for _, q := range order { + i := strings.Index(about, q) + if i < 0 { + t.Fatalf("the about page does not quote %q", q) + } + if i < last { + t.Errorf("the about page quotes %q out of id order; want %q", q, order) + } + last = i + } +} + +// gitLogWrapper puts a git on PATH that records each invocation's arguments +// and then runs the real one, and returns the log's path. +func gitLogWrapper(t *testing.T) string { + t.Helper() + real, err := exec.LookPath("git") + if err != nil { + t.Skip("no git on PATH") + } + dir := t.TempDir() + logPath := filepath.Join(dir, "calls.log") + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '" + logPath + "'\nexec '" + real + "' \"$@\"\n" + if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +// historyCalls are the logged git invocations that read history: a log, a +// shortlog or a blame. +func historyCalls(t *testing.T, logPath string) []string { + t.Helper() + data, err := os.ReadFile(logPath) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + var out []string + for _, line := range strings.Split(string(data), "\n") { + for _, arg := range strings.Fields(line) { + if arg == "log" || arg == "shortlog" || arg == "blame" || arg == "whatchanged" { + out = append(out, line) + break + } + } + } + return out +} + +// A project build and its gate never ask git for history. The composed build, +// run under the same wrapper, does, which is what shows the wrapper sees the +// calls at all. +func TestAProjectBuildNeverReadsGitHistory(t *testing.T) { + f := newProjectHistoryFixture(t) + composed := newFixture(t) + logPath := gitLogWrapper(t) + + out := t.TempDir() + if _, err := Build(Request{RepoRoot: f.Root(), OutDir: out, Stamp: fixtureStamp, Project: true}); err != nil { + t.Fatalf("project build: %v", err) + } + if _, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}); err != nil { + t.Fatalf("project gate: %v", err) + } + if calls := historyCalls(t, logPath); len(calls) > 0 { + t.Errorf("a project build read git history:\n%s", strings.Join(calls, "\n")) + } + + if _, err := Build(Request{RepoRoot: composed.Root(), OutDir: t.TempDir(), Stamp: fixtureStamp}); err != nil { + t.Fatalf("composed build: %v", err) + } + if len(historyCalls(t, logPath)) == 0 { + t.Error("the composed build read no git history through the wrapper; the wrapper sees nothing") + } +} + +// The composed route keeps every git-history detail: the authorship and +// history blocks and each record's dates in record.json, the timeline, and the +// health page's authorship checks. +func TestTheComposedRouteKeepsItsGitHistoryDetails(t *testing.T) { + f := newFixture(t) + out := t.TempDir() + buildFixture(t, f, out) + files := siteFiles(t, out) + var export map[string]json.RawMessage + if err := json.Unmarshal(files["record.json"], &export); err != nil { + t.Fatal(err) + } + for _, key := range []string{"authorship", "history"} { + if _, ok := export[key]; !ok { + t.Errorf("the composed record.json lost %q", key) + } + } + if !strings.Contains(string(files["record.json"]), `"dates": {`) { + t.Error("the composed record.json lost the records' dates") + } + if !strings.Contains(string(files[routeDashboard+"index.html"]), `class="tlsvg"`) { + t.Error("the composed dashboard lost its timeline") + } + ui, err := LoadUI(f.Root(), "site-src/ui.json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(files[routeHealth+"index.html"]), escapeText(ui.Health.SameAuthor)) { + t.Error("the composed health page lost its same-author panel") + } +} diff --git a/internal/core/site/project_test.go b/internal/core/site/project_test.go new file mode 100644 index 000000000..893ecc7c8 --- /dev/null +++ b/internal/core/site/project_test.go @@ -0,0 +1,574 @@ +package site + +// The project route (itd-2610091010348892, the product thinker's ruling of +// 2026-10-15 that a project's site renders only what abcd already writes). +// A project site's `.abcd/site.json` holds settings only; its about page is +// derived from the identity block, the brief's opening, the shipped intents' +// press releases and CHANGELOG.md; its interface words, styles and scripts come +// from the binary; and the gate refuses any span from a file abcd does not +// write. + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// projectSettingsJSON is a project site's settings file as setup writes it. +const projectSettingsJSON = `{ + "schema_version": 1, + "mode": "project", + "purpose": "The fixture's project-site settings.", + "hosting": { + "provider": "github-pages", + "name": "fixture", + "publish": true + } +} +` + +// Words that sit only in a person's own files: the README and the docs. A +// project site renders none of them. +const ( + readmeOnlyWords = "Hand-written readme words the site never shows." + docsOnlyWords = "The fixture's documentation index." +) + +// newProjectFixture is the fixture repository switched to the project route: +// its composition replaced by project settings, its site sources removed, an +// identity pointer and a brief opening added, and a README beside its docs. +func newProjectFixture(t *testing.T) *fixture { + t.Helper() + f := newFixture(t) + f.write(ManifestRelPath, projectSettingsJSON) + if err := os.RemoveAll(filepath.Join(f.Root(), "site-src")); err != nil { + t.Fatal(err) + } + f.write(".abcd/positioning.json", `{"schema_version": 1, "block": {"file": ".abcd/development/brief/01-product/README.md", "heading": "Identity (canonical)"}, "severity": "warn", "surfaces": []}`+"\n") + f.write(BriefOpeningRelPath, "# Press Release\n\n## Press Release\n\n"+ + "> **The fixture brief opens here.** It says what the fixture is for, in the brief's own words.\n\n"+ + "## Why This Matters\n\nA later section the opening does not reach.\n") + f.write("README.md", "# Fixture\n\n"+readmeOnlyWords+"\n") + f.commitAt("2026-03-07T09:00:00+00:00", "chore: the project route", "None") + // A managed repository on GitHub: the origin remote is where a project + // site's repository address and name come from. + f.git("2026-03-07T09:00:00+00:00", "remote", "add", "origin", "https://github.com/"+projectOrigin+".git") + return f +} + +// projectOrigin is the project fixture's GitHub repository, from its origin +// remote. +const ( + projectOrigin = "fixture-owner/fixture-repo" + projectOriginURL = "https://github.com/" + projectOrigin + projectOriginName = "fixture-repo" +) + +func buildProject(t *testing.T, f *fixture) (string, map[string][]byte) { + t.Helper() + out := t.TempDir() + if _, err := Build(Request{RepoRoot: f.Root(), OutDir: out, Stamp: fixtureStamp, Project: true}); err != nil { + t.Fatalf("project build: %v", err) + } + return out, siteFiles(t, out) +} + +var dataSrcRe = regexp.MustCompile(`data-src="([^"#]*)`) + +func TestAProjectBuildRendersItsAboutPageFromWhatAbcdWrites(t *testing.T) { + f := newProjectFixture(t) + out, files := buildProject(t, f) + about := string(files["index.html"]) + for _, want := range []string{ + // The identity block, as the hero. + `<p class="eyebrow" data-src=".abcd/development/brief/01-product/README.md#identity-canonical">fixture — A Fixture Product</p>`, + `<h1 class="tagline" data-src=".abcd/development/brief/01-product/README.md#identity-canonical">A fixture tagline for a fixture product.</h1>`, + // The brief's opening, and nothing after it. + `data-src="` + BriefOpeningRelPath + `#press-release">Press Release</h2>`, + "The fixture brief opens here.", + // The newest shipped intent the record shows delivered. + "A fixture user opens the page and sees the record.", + // The release list. + `>v0.2.0</a> · 2026-02-11</li>`, `>v0.1.0</a> · 2026-01-06</li>`, + } { + if !strings.Contains(about, want) { + t.Errorf("the about page lacks %q", want) + } + } + if strings.Contains(about, "A later section the opening does not reach.") { + t.Error("the about page renders the brief past its opening") + } + for rel, data := range files { + for _, words := range []string{readmeOnlyWords, docsOnlyWords, "Who this is for"} { + if strings.Contains(string(data), words) { + t.Errorf("%s renders a person's own words %q", rel, words) + } + } + for _, m := range dataSrcRe.FindAllSubmatch(data, -1) { + if !abcdWrittenSource(string(m[1])) { + t.Errorf("%s carries a span from %q, which abcd does not write", rel, m[1]) + } + } + } + // The interface words, styles and scripts are the binary's; the + // repository carries no site sources, and nothing of abcd's own (the + // redirect map, the installer) is served. + for src, dst := range map[string]string{"site.css": "site.css", "site.js": "site.js", "record.js": "record.js", "headers": "_headers"} { + want, err := setupSources.ReadFile("setupsrc/" + src) + if err != nil { + t.Fatal(err) + } + if string(files[dst]) != string(want) { + t.Errorf("%s is not the binary's %s", dst, src) + } + } + for _, absent := range []string{"_redirects", "install.sh"} { + if _, ok := files[absent]; ok { + t.Errorf("a project site serves %s", absent) + } + } + // The pages that quote a person's files are left out. + if _, ok := files["references/index.html"]; ok { + t.Error("a project site renders the references page, which quotes ACKNOWLEDGEMENTS.md") + } + if strings.Contains(about, `href="/#`) { + t.Error("the header links a composed chapter the about page does not have") + } + + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + for _, fd := range res.Findings { + t.Errorf("finding: %+v", fd) + } + } +} + +// Anything absent is left out, never filled: a project with a changelog and +// nothing else gets the release list alone. +func TestAnAboutPageLeavesOutWhatTheRepositoryDoesNotHold(t *testing.T) { + f := newProjectFixture(t) + for _, rel := range []string{".abcd/positioning.json", BriefOpeningRelPath} { + if err := os.Remove(filepath.Join(f.Root(), filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + } + _, files := buildProject(t, f) + about := string(files["index.html"]) + if strings.Contains(about, `class="hero"`) || strings.Contains(about, "The fixture brief opens here.") { + t.Error("the about page renders a part the repository does not hold") + } + if !strings.Contains(about, `id="releases"`) { + t.Error("the about page lacks the release list the changelog holds") + } +} + +// The gate's project half: a span from docs/ or the README fails, on the about +// page and on a record page alike. +func TestTheProjectGateRefusesASpanFromDocsOrTheReadme(t *testing.T) { + f := newProjectFixture(t) + out, files := buildProject(t, f) + inject := `<p data-src="docs/README.md">x</p><p data-src="README.md#fixture">y</p></main>` + var record string + for rel := range files { + if strings.HasPrefix(rel, "record/adr/") && strings.HasSuffix(rel, "index.html") { + record = rel + break + } + } + if record == "" { + t.Fatal("the build wrote no ADR page") + } + for _, rel := range []string{"index.html", record} { + page := strings.Replace(string(files[rel]), "</main>", inject, 1) + if err := os.WriteFile(filepath.Join(out, filepath.FromSlash(rel)), []byte(page), 0o644); err != nil { + t.Fatal(err) + } + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + for _, rel := range []string{"index.html", record} { + for _, src := range []string{"docs/README.md", "README.md#fixture"} { + found := false + for _, fd := range res.Findings { + if fd.Check == CheckProvenance && fd.Where == rel && fd.Source == src && strings.Contains(fd.Detail, "abcd does not write") { + found = true + } + } + if !found { + t.Errorf("no provenance finding on %s for the span from %s; findings: %+v", rel, src, res.Findings) + } + } + } +} + +// The route is named, never inferred: a build whose flag and settings file +// disagree is refused, both ways. +func TestABuildAndItsSettingsAgreeOnTheRoute(t *testing.T) { + project := newProjectFixture(t) + _, err := Build(Request{RepoRoot: project.Root(), OutDir: t.TempDir(), Stamp: fixtureStamp}) + if err == nil || !strings.Contains(err.Error(), "--project") { + t.Fatalf("a composed build of project settings = %v, want a refusal naming --project", err) + } + if _, err := Check(CheckRequest{RepoRoot: project.Root(), OutDir: t.TempDir()}); err == nil || !strings.Contains(err.Error(), "--project") { + t.Fatalf("a composed gate over project settings = %v, want a refusal naming --project", err) + } + composed := newFixture(t) + _, err = Build(Request{RepoRoot: composed.Root(), OutDir: t.TempDir(), Stamp: fixtureStamp, Project: true}) + if err == nil || !strings.Contains(err.Error(), "composition") { + t.Fatalf("a project build of a composition = %v, want a refusal", err) + } +} + +// A project site's settings carry settings and nothing else. +func TestProjectSettingsCarryNoComposition(t *testing.T) { + for name, c := range map[string]struct{ body, want string }{ + "a composition key": {strings.Replace(projectSettingsJSON, `"hosting"`, `"home": {"hero": {"page": "docs/README.md"}}, "hosting"`, 1), "home is a composition key"}, + "no hosting block": {`{"schema_version": 1, "mode": "project", "purpose": "x"}`, "need a hosting block"}, + "a composed host": {strings.Replace(projectSettingsJSON, `"publish": true`, `"domain": "example.com"`, 1), ""}, + "an unknown mode": {strings.Replace(projectSettingsJSON, `"project"`, `"other"`, 1), `mode "other"`}, + } { + t.Run(name, func(t *testing.T) { + f := newProjectFixture(t) + body := c.body + if name == "a composed host" { + body = strings.Replace(body, `"github-pages"`, `"cloudflare"`, 1) + c.want = "publishes a composed site" + } + f.write(ManifestRelPath, body) + _, err := LoadManifest(f.Root()) + if !errors.Is(err, ErrManifestInvalid) || !strings.Contains(err.Error(), c.want) { + t.Fatalf("LoadManifest = %v, want a refusal naming %q", err, c.want) + } + }) + } + f := newProjectFixture(t) + m, err := LoadManifest(f.Root()) + if err != nil || !m.Project() || !m.Hosting.On() { + t.Fatalf("project settings = %+v, %v", m, err) + } +} + +// Every page of a project site opens with the managed-repository line, in the +// binary's words, linking the repository. +func TestEveryProjectPageOpensWithTheManagedRepositoryLine(t *testing.T) { + f := newProjectFixture(t) + _, files := buildProject(t, f) + ui, err := projectUI() + if err != nil { + t.Fatal(err) + } + want := managedLine(projectOriginURL, escapeText(ui.ManagedRecord)) + pages := 0 + for rel, data := range files { + if !strings.HasSuffix(rel, ".html") { + continue + } + pages++ + if !strings.Contains(string(data), want) { + t.Errorf("%s does not open with the managed-repository line", rel) + } + } + if pages < 5 { + t.Fatalf("only %d pages were built", pages) + } +} + +// A project site below its GitHub Pages path links nothing outside it, and the +// gate holds it there. +func TestAProjectSiteBuildsBelowItsBasePath(t *testing.T) { + f := newProjectFixture(t) + out := t.TempDir() + if _, err := Build(Request{RepoRoot: f.Root(), OutDir: out, Stamp: fixtureStamp, Project: true, BasePath: "/fixture/"}); err != nil { + t.Fatal(err) + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true, BasePath: "/fixture/"}) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + t.Fatalf("findings: %+v", res.Findings) + } +} + +// A project site's repository facts are the origin remote's alone. The +// package manifest is a file a person writes, so its name, author, licence and +// repository address reach no page and pass no gate; on a fork the managed +// line links the fork, never the upstream the manifest names. +func TestAProjectSiteReadsNothingFromThePackageManifest(t *testing.T) { + f := newProjectFixture(t) + f.write(".claude-plugin/plugin.json", `{ + "name": "Quillfeather", + "description": "A hostile manifest.", + "author": {"name": "Ottoline Brackenbury"}, + "repository": "https://github.com/upstream-elsewhere/quillfeather", + "license": "Licensed under the Brackenbury Terms of Gentle Reuse" +} +`) + f.commitAt("2026-03-08T09:00:00+00:00", "chore: a manifest of its own", "None") + out, files := buildProject(t, f) + for rel, data := range files { + for _, words := range []string{"Quillfeather", "quillfeather", "Ottoline", "Brackenbury", "Gentle Reuse", "upstream-elsewhere"} { + if strings.Contains(string(data), words) { + t.Errorf("%s carries %q from the package manifest", rel, words) + } + } + if strings.HasSuffix(rel, ".html") && !strings.Contains(string(data), `<p class="managed"><small><a href="`+projectOriginURL+`">`) { + t.Errorf("%s: the managed line does not link the origin remote", rel) + } + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + t.Fatalf("findings: %+v", res.Findings) + } +} + +// A project with no identity block is titled by its repository's name on +// GitHub: the head, the brand and the about page's heading carry it, and a +// record page reads "<page> · <name>", never a name with nothing after it. +func TestAProjectWithoutAnIdentityBlockIsTitledByItsRepositoryName(t *testing.T) { + f := newProjectFixture(t) + if err := os.Remove(filepath.Join(f.Root(), ".abcd", "positioning.json")); err != nil { + t.Fatal(err) + } + f.commitAt("2026-03-08T09:00:00+00:00", "chore: no identity pointer", "None") + out, files := buildProject(t, f) + about := string(files["index.html"]) + for _, want := range []string{ + "<title>" + projectOriginName + "", + `` + projectOriginName + ``, + `

` + projectOriginName + `

`, + } { + if !strings.Contains(about, want) { + t.Errorf("the about page lacks %q", want) + } + } + if strings.Contains(about, `class="hero"`) { + t.Error("the about page renders a hero with no identity block") + } + dash := string(files["record/index.html"]) + if !strings.Contains(dash, "Dashboard · "+projectOriginName+"") { + t.Errorf("the dashboard's title is not the page and the repository name: %s", firstMatch(`[^<]*`, dash)) + } + for rel, data := range files { + if strings.Contains(string(data), " · ") || strings.Contains(string(data), "") { + t.Errorf("%s is titled with no name", rel) + } + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + t.Fatalf("findings: %+v", res.Findings) + } +} + +// firstMatch returns the first match of pattern in s, for a failure message. +func firstMatch(pattern, s string) string { + return regexp.MustCompile(pattern).FindString(s) +} + +// A managed repository keeps its record in abcd's standard layout and need +// carry no `.abcd/record-lint.json`: nothing writes one for it. Its project +// site reads the record from that layout, so its intents and record pages are +// the same as a repository that declares the layout in the file. +func TestAProjectSiteReadsTheStandardLayoutWithoutARecordLintConfig(t *testing.T) { + f := newProjectFixture(t) + withCfg := t.TempDir() + declared, err := Build(Request{RepoRoot: f.Root(), OutDir: withCfg, Stamp: fixtureStamp, Project: true}) + if err != nil { + t.Fatal(err) + } + want := siteFiles(t, withCfg) + if err := os.Remove(filepath.Join(f.Root(), ".abcd", "record-lint.json")); err != nil { + t.Fatal(err) + } + // Left uncommitted, so the history both builds read is the same. + out := t.TempDir() + res, err := Build(Request{RepoRoot: f.Root(), OutDir: out, Stamp: fixtureStamp, Project: true}) + if err != nil { + t.Fatal(err) + } + if res.Records == 0 || res.Records != declared.Records { + t.Fatalf("records = %d, want the %d the declared layout reads", res.Records, declared.Records) + } + got := siteFiles(t, out) + about := string(got["index.html"]) + if !strings.Contains(about, `id="shipped"`) || !strings.Contains(about, "A fixture user opens the page and sees the record.") { + t.Error("the about page lacks the shipped intents' section") + } + if _, ok := got["record/intent/itd-2/index.html"]; !ok { + t.Error("no record page for a shipped intent") + } + for rel, data := range want { + if rel == "record.json" { + continue + } + if g, ok := got[rel]; !ok { + t.Errorf("%s is built from the declared layout and not from the standard one", rel) + } else if string(g) != string(data) { + t.Errorf("%s differs between the declared layout and the standard one", rel) + } + } + for rel := range got { + if _, ok := want[rel]; !ok { + t.Errorf("%s is built from the standard layout only", rel) + } + } + check, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + if !check.OK() { + t.Fatalf("findings: %+v", check.Findings) + } +} + +// The standard layout publishes the issue ledger only where the settings opt +// in to publishing it, as a declared layout does. +func TestTheStandardLayoutPublishesTheIssueLedgerOnlyWhenOptedIn(t *testing.T) { + for _, optIn := range []bool{false, true} { + f := newProjectFixture(t) + if err := os.Remove(filepath.Join(f.Root(), ".abcd", "record-lint.json")); err != nil { + t.Fatal(err) + } + if optIn { + f.write(ManifestRelPath, strings.Replace(projectSettingsJSON, `"hosting"`, `"record": {"issue_ledger": true}, "hosting"`, 1)) + } + _, files := buildProject(t, f) + _, issuePage := files["record/issue/iss-1/index.html"] + if issuePage != optIn { + t.Errorf("opted in = %v, but an issue page is built = %v", optIn, issuePage) + } + } +} + +// A project site has no contributors page: its names and declared tools are +// read out of git history, which is not something abcd writes. Nothing links +// to it either. The composed route keeps it. +func TestAProjectSiteHasNoContributorsPage(t *testing.T) { + _, files := buildProject(t, newProjectFixture(t)) + if _, ok := files["contributors/index.html"]; ok { + t.Error("a project site renders the contributors page") + } + for rel, data := range files { + if strings.Contains(string(data), `/contributors/"`) { + t.Errorf("%s links the contributors page", rel) + } + } + composed := newFixture(t) + out := t.TempDir() + if _, err := Build(Request{RepoRoot: composed.Root(), OutDir: out, Stamp: fixtureStamp}); err != nil { + t.Fatal(err) + } + if _, ok := siteFiles(t, out)["contributors/index.html"]; !ok { + t.Error("the composed route lost its contributors page") + } +} + +// A project build with nothing abcd wrote for its about page is refused, as +// setup refuses it, before anything is written: the page would be empty. +func TestAProjectBuildWithNothingAbcdWroteIsRefused(t *testing.T) { + f := newProjectFixture(t) + for _, rel := range []string{".abcd/positioning.json", BriefOpeningRelPath, changelogFile, ".abcd/development/intents/shipped"} { + if err := os.RemoveAll(filepath.Join(f.Root(), filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + } + f.commitAt("2026-03-08T09:00:00+00:00", "chore: nothing for the about page", "None") + out := filepath.Join(t.TempDir(), "site") + _, err := Build(Request{RepoRoot: f.Root(), OutDir: out, Stamp: fixtureStamp, Project: true}) + if !errors.Is(err, ErrProjectEmpty) { + t.Fatalf("a project build with nothing abcd wrote = %v, want ErrProjectEmpty", err) + } + if _, serr := os.Stat(out); !os.IsNotExist(serr) { + t.Errorf("the refused build wrote its output directory: %v", serr) + } +} + +// An intent may cite an issue by a typed field (`refines:`, written by the +// filing-time match). Without a record-lint config and without the ledger +// opt-in, the standard layout still reads the issue store, so the reference +// resolves and the gate passes; the export filter alone keeps the issue off +// the site. +func TestTheStandardLayoutResolvesAnIssueAnIntentRefinesWithoutPublishingIt(t *testing.T) { + f := newProjectFixture(t) + if err := os.Remove(filepath.Join(f.Root(), ".abcd", "record-lint.json")); err != nil { + t.Fatal(err) + } + f.write(".abcd/development/intents/planned/itd-9-refines-an-issue.md", `--- +id: itd-9 +slug: refines-an-issue +spec_id: null +kind: standalone +builds_on: [] +refines: [iss-1] +severity: minor +impact: additive +--- + +# Refines An Issue + +## Press Release + +An intent that refines a ledger issue. +`) + f.commitAt("2026-03-08T09:00:00+00:00", "chore: an intent refining an issue", "None") + out, files := buildProject(t, f) + for rel := range files { + if strings.HasPrefix(rel, "record/issue/") { + t.Errorf("%s is published without the ledger opt-in", rel) + } + } + if strings.Contains(string(files["record.json"]), `"type":"issue"`) { + t.Error("record.json carries an issue without the ledger opt-in") + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + t.Fatalf("findings: %+v", res.Findings) + } +} + +// A project site is titled by its identity block, or failing that by its +// repository's name on GitHub. With neither, the build renders an empty +// `` and brand, and the gate refuses it, naming both remedies. +func TestTheProjectGateRefusesASiteWithNoTitle(t *testing.T) { + f := newProjectFixture(t) + if err := os.Remove(filepath.Join(f.Root(), ".abcd", "positioning.json")); err != nil { + t.Fatal(err) + } + f.commitAt("2026-03-08T09:00:00+00:00", "chore: no identity pointer", "None") + f.git("2026-03-08T09:00:00+00:00", "remote", "remove", "origin") + out, files := buildProject(t, f) + if !strings.Contains(string(files["index.html"]), "<title>") { + t.Fatalf("the fixture is not untitled: %s", firstMatch(`[^<]*`, string(files["index.html"]))) + } + res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: out, Project: true}) + if err != nil { + t.Fatal(err) + } + found := false + for _, fd := range res.Findings { + if fd.Check == CheckProvenance && strings.Contains(fd.Detail, "no title") && + strings.Contains(fd.Detail, "identity block") && strings.Contains(fd.Detail, "GitHub origin") { + found = true + } + } + if res.OK() || !found { + t.Fatalf("the gate passes an untitled project site, or names no remedy: %+v", res.Findings) + } +} diff --git a/internal/core/site/provenancepin_test.go b/internal/core/site/provenancepin_test.go index 9cbabff4e..1a3edb76f 100644 --- a/internal/core/site/provenancepin_test.go +++ b/internal/core/site/provenancepin_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/adapter/hosting" "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare" ) @@ -73,7 +74,7 @@ func TestSiteWorkflowVerifiesTheBinaryAgainstReleaseYMLOnMain(t *testing.T) { // repository's site renders with abcd's released binary, held to abcd's // release.yml on main and to the commit of the release it downloaded. func TestTheSetupWorkflowVerifiesAbcdAgainstReleaseYMLOnMain(t *testing.T) { - wf, err := renderSiteWorkflow("trunk", cloudflare.Adapter{}) + wf, err := renderSiteWorkflow("trunk", cloudflare.Adapter{}, hosting.Site{Name: "x"}, false) if err != nil { t.Fatal(err) } diff --git a/internal/core/site/providers.go b/internal/core/site/providers.go index 961c1f79c..e046de3aa 100644 --- a/internal/core/site/providers.go +++ b/internal/core/site/providers.go @@ -1,16 +1,36 @@ package site import ( + "context" "strings" "github.com/intentdriven/abcd/internal/adapter/hosting" "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare" + "github.com/intentdriven/abcd/internal/adapter/hosting/githubpages" + "github.com/intentdriven/abcd/internal/core/github" ) // 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{}} +var adapters = []hosting.Adapter{cloudflare.Adapter{}, githubpages.Adapter{API: ghPagesAPI(github.Run)}} + +// ghPagesAPI is the github-pages provider's forge: `gh api` as the person who +// invoked the verb, exactly as the forge stage speaks (ghForge.api), so abcd +// holds no GitHub token for it either. +func ghPagesAPI(gh func(dir string, stdin []byte, args ...string) ([]byte, error)) githubpages.API { + return func(_ context.Context, method, path string, body []byte) ([]byte, error) { + args := []string{"api", "--hostname", github.Host, "-H", "Accept: application/vnd.github+json"} + if method != "" && method != "GET" { + args = append(args, "--method", method) + } + args = append(args, path) + if body != nil { + args = append(args, "--input", "-") + } + return gh("", body, args...) + } +} // DefaultProvider is the provider setup routes through when the manifest names // none. diff --git a/internal/core/site/recordjson.go b/internal/core/site/recordjson.go index 35471aa49..55ae8acd0 100644 --- a/internal/core/site/recordjson.go +++ b/internal/core/site/recordjson.go @@ -63,14 +63,16 @@ type RecordExport struct { // Releases are the dated CHANGELOG headings, newest first. Absent where the // repository records none. Releases []changelog.DatedRelease `json:"releases"` - // Authorship is who wrote the history and what assisted. - Authorship Authorship `json:"authorship"` + // Authorship is who wrote the history and what assisted. A project site + // carries none: it is read from git history, which abcd does not write. + Authorship *Authorship `json:"authorship,omitempty"` // Health is the record's own reference hygiene, measured. Health Health `json:"health"` // Layout carries the two precomputed arrangements. Layout Arrangements `json:"layout"` - // History summarises the git walk the dates came from. - History HistoryMeta `json:"history"` + // History summarises the git walk the dates came from. A project site + // carries none, for the same reason. + History *HistoryMeta `json:"history,omitempty"` } // BuildStamp is what produced this file. Every field is injected by the caller @@ -104,15 +106,16 @@ type ExportNode struct { Kind string `json:"kind,omitempty"` Severity string `json:"severity,omitempty"` // Date is the record's effective date: its own frontmatter date where it - // carries one, else the day its file first appeared in git. + // carries one, else the day its file first appeared in git. A project + // site reads no git, so there it is the frontmatter date or empty. Date string `json:"date"` // Derived is true for a record that declares NO frontmatter — its id is its // file name, its title its first heading, its dates git's. A page must not // present those as fields the record stated, and a chart must not read a // lifecycle it never had. Derived bool `json:"derived,omitempty"` - // Dates are the file's three git dates. - Dates FileDates `json:"dates"` + // Dates are the file's three git dates. A project site carries none. + Dates *FileDates `json:"dates,omitempty"` // Degree is the weighted connectedness the chart sizes bubbles by. Degree float64 `json:"degree"` } @@ -164,6 +167,33 @@ type HistoryMeta struct { Commits int `json:"commits"` } +// authorship is the export's authorship, or a zero one where the export +// carries none (the project route). +func (x RecordExport) authorship() Authorship { + if x.Authorship == nil { + return Authorship{} + } + return *x.Authorship +} + +// firstCommit is the day of the history's oldest commit, or "" where the +// export carries no history (the project route). +func (x RecordExport) firstCommit() string { + if x.History == nil { + return "" + } + return x.History.FirstCommit +} + +// dates is the record's git dates, or zero ones where the export carries none +// (the project route). +func (n ExportNode) dates() FileDates { + if n.Dates == nil { + return FileDates{} + } + return *n.Dates +} + // relationOf maps a frontmatter field to the relation it declares, and to // whether it declares it from the far end. The record spells one link from both // sides — an intent's `spec_id` and its spec's `intent` are the same link — so @@ -190,6 +220,19 @@ var symmetricRel = map[string]bool{"related": true, "implements": true} // BuildRecordExport derives record.json from the record graph, the git history // and the changelog. func BuildRecordExport(repoRoot, baselineRel string, graph lint.RecordGraph, extra []lint.RecordNode, hist History, stamp BuildStamp, opts RecordOpts) (RecordExport, error) { + return buildRecordExport(repoRoot, baselineRel, graph, extra, &hist, stamp, opts) +} + +// buildRecordExport is BuildRecordExport with the git history optional. A nil +// hist is the project route: a project site renders only what abcd writes, and +// git history is not that, so the export carries no authorship, no history +// block and no per-record git dates, and nothing here asks git for them. Each +// record is placed by its own frontmatter date alone. +func buildRecordExport(repoRoot, baselineRel string, graph lint.RecordGraph, extra []lint.RecordNode, hist *History, stamp BuildStamp, opts RecordOpts) (RecordExport, error) { + gitHistory := hist != nil + if hist == nil { + hist = &History{} + } nodes := graph.Nodes derived := map[string]bool{} if len(extra) > 0 { @@ -271,14 +314,20 @@ func BuildRecordExport(repoRoot, baselineRel string, graph lint.RecordGraph, ext Mentions: mentions, Counts: Counts{ByType: map[string]int{}, ByLifecycle: map[string]map[string]int{}, ByStatus: map[string]map[string]int{}}, - History: HistoryMeta{FirstCommit: hist.First, LastCommit: hist.Last, Commits: hist.Commits}, + } + if gitHistory { + exp.History = &HistoryMeta{FirstCommit: hist.First, LastCommit: hist.Last, Commits: hist.Commits} } for i, n := range nodes { date := hist.EffectiveDate(n.Path, n.Date) exp.Nodes[i] = ExportNode{ ID: n.ID, Type: n.Type, Lifecycle: n.Lifecycle, Title: n.Title, Path: n.Path, Status: n.Status, Kind: n.Kind, Severity: n.Severity, - Date: date, Dates: hist.Files[n.Path], Derived: derived[n.ID], + Date: date, Derived: derived[n.ID], + } + if gitHistory { + dates := hist.Files[n.Path] + exp.Nodes[i].Dates = &dates } lay[i] = LayoutNode{Type: n.Type, Date: date, Num: handleNum(n.ID), Touched: hist.Files[n.Path].Touched} exp.Counts.ByType[n.Type]++ @@ -300,11 +349,16 @@ func BuildRecordExport(repoRoot, baselineRel string, graph lint.RecordGraph, ext } for _, n := range decisionNodes { - exp.Decisions = append(exp.Decisions, ExportNode{ + d := ExportNode{ ID: n.ID, Type: n.Type, Lifecycle: n.Lifecycle, Title: n.Title, Path: n.Path, Status: n.Status, Kind: n.Kind, Severity: n.Severity, - Date: hist.EffectiveDate(n.Path, n.Date), Dates: hist.Files[n.Path], - }) + Date: hist.EffectiveDate(n.Path, n.Date), + } + if gitHistory { + dates := hist.Files[n.Path] + d.Dates = &dates + } + exp.Decisions = append(exp.Decisions, d) } sortDecisionNodes(exp.Decisions) @@ -317,9 +371,12 @@ func BuildRecordExport(repoRoot, baselineRel string, graph lint.RecordGraph, ext exp.Releases = []changelog.DatedRelease{} } - exp.Authorship, err = LoadAuthorship(repoRoot) - if err != nil { - return RecordExport{}, err + if gitHistory { + auth, err := LoadAuthorship(repoRoot) + if err != nil { + return RecordExport{}, err + } + exp.Authorship = &auth } exp.Health, err = measureHealth(repoRoot, baselineRel, graph, index) diff --git a/internal/core/site/recordpage.go b/internal/core/site/recordpage.go index 26d0ccb54..46aa5cc76 100644 --- a/internal/core/site/recordpage.go +++ b/internal/core/site/recordpage.go @@ -208,7 +208,8 @@ const ( ) // frontmatterTable is the record's own fields, plus the three dates git carries -// for its file. Every cell is a value the record already holds. +// for its file. Every cell is a value the record already holds. A project site +// carries no git dates, so its rows are the record's own fields alone. func (e *explorer) frontmatterTable(n ExportNode) string { rows := [][2]string{ {"id", n.ID}, @@ -218,9 +219,9 @@ func (e *explorer) frontmatterTable(n ExportNode) string { {"kind", n.Kind}, {"severity", n.Severity}, {fieldDate, n.Date}, - {fieldCreated, n.Dates.Created}, - {fieldEntered, n.Dates.Entered}, - {fieldTouched, n.Dates.Touched}, + {fieldCreated, n.dates().Created}, + {fieldEntered, n.dates().Entered}, + {fieldTouched, n.dates().Touched}, } var b strings.Builder b.WriteString(`
`) @@ -241,8 +242,13 @@ func (e *explorer) frontmatterTable(n ExportNode) string { // // The path is a file name, which the generator may print; everything else here // is a ui.json label. +// +// A project site reads no git history, so it cannot tell whether git has +// touched the file; it offers the link always, since a project site is built +// from the committed tree, and the history is read on the forge rather than on +// the site. func (e *explorer) fileLinks(n ExportNode) string { - return e.forgeFileLinks(n.Path, n.Dates.Touched != "") + return e.forgeFileLinks(n.Path, e.c.project || n.dates().Touched != "") } // forgeFileLinks is fileLinks for any repository file the site puts a page on — @@ -327,7 +333,7 @@ func (e *explorer) linkRow(id, rel string) string { return "" } var b strings.Builder - b.WriteString(`
  • ` + escapeText(id) + ``) + b.WriteString(`
  • ` + escapeText(id) + ``) b.WriteString(`` + escapeText(shortTitle(target)) + ``) if rel != "" { b.WriteString(`` + escapeText(rel) + ``) diff --git a/internal/core/site/setup.go b/internal/core/site/setup.go index a579d567b..eae727eef 100644 --- a/internal/core/site/setup.go +++ b/internal/core/site/setup.go @@ -15,6 +15,10 @@ package site // 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 no remote change attempted — unless it is confirmed. +// A provider that publishes a project site (github-pages) writes only the +// site's settings, holding the switch, and the workflow: no composition +// and no site sources, because a project site renders only what abcd +// already writes (project.go). One with nothing to render is refused. // 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 @@ -36,6 +40,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -69,6 +74,12 @@ const ( EnvDeploy = "site" ) +// projectReleaseNote states the project route's dependency on abcd's own +// releases: the workflow builds with abcd's latest release, and a release made +// before project sites existed has no `site build --project`. +const projectReleaseNote = "the workflow builds with abcd's latest release, so its build fails until abcd publishes a release " + + "that builds project sites (one whose `abcd site build` takes --project)" + // abcdReleaseRepo is where the workflow downloads the abcd binary it renders // with, and whose release workflow must have signed it. const abcdReleaseRepo = "intentdriven/abcd" @@ -114,6 +125,10 @@ const ( HostNotReached = "not_reached" ) +// FileRemoved is a file outcome: setup removed the release workflow it added, +// because the project site is switched off. +const FileRemoved scaffold.FileStatus = "removed" + // 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)") @@ -129,10 +144,21 @@ type SetupRequest struct { // Name and Domain seed the hosting block when the composition has none. // Name defaults to the repository's own name. Name, Domain string + // Provider seeds the hosting block's provider when the composition has + // none; empty is DefaultProvider. A provider with a switch (github-pages) + // is switched on only by a yes to the question setup asks. + Provider string // Confirm replaces machinery that drifted from what setup writes. Confirm bool // Asker confirms each remote write; nil declines them all. Asker Asker + // Unattended says the Asker answers for no one (--yes). It is never put + // the takeover of a site the host already serves: an unattended run that + // meets one refuses, naming it, unless ReplaceExisting answered it. + Unattended bool + // ReplaceExisting answers the takeover in advance (--replace-existing): + // setup publishes over a site the host already serves without asking. + ReplaceExisting bool // Forge is the repository's forge; nil resolves GitHub through gh. Forge Forge // ConfirmTool answers the offer to install a missing gh when Forge is nil @@ -233,7 +259,51 @@ func Setup(req SetupRequest) (SetupResult, error) { offerNote = strings.Join(offer, "\n") } } + if forge != nil { + s.Repo = forge.Repo() + } else if repo, rerr := ahoy.GitHubRepo(root); rerr == nil { + s.Repo = repo + } + prof := adapter.Profile(s) + switchedOff := prof.Switched && manifestBytes == nil && !hostingBlock.On() + project := manifest.Project() + // A project site is built from what abcd writes; with nothing written yet + // it would be an empty page, so it is not switched on. A switched-off site + // is exempt: setup only offers to take its workflow away. + if project && !switchedOff { + ok, err := projectHasContent(root) + if err != nil { + return SetupResult{}, fmt.Errorf("site setup: %w", err) + } + if !ok { + return SetupResult{}, ErrProjectEmpty + } + } + // A public-only provider refuses a private repository before anything is + // asked or written. A switched-off site is exempt: setup only offers to + // take its workflow away. + // + // This reads GET repos/{repo}, and the github-pages adapter's Inspect + // reads it again, as DefaultBranch does. The repeat is kept: the reads sit + // on two seams (the Forge here, a hosting.Adapter's own API there), the + // check here holds for any public-only provider whatever its Inspect does, + // and passing one response through would add the forge's repository + // document to the provider seam for a saving of two small reads per run. + if prof.PublicOnly && !switchedOff { + if forge == nil { + return SetupResult{}, fmt.Errorf("site setup: %s publishes public repositories only, and whether this one is public could not be read from the forge", adapter.Name()) + } + private, perr := forge.Private(ctx) + if perr != nil { + return SetupResult{}, fmt.Errorf("site setup: %s publishes public repositories only, and whether %s is public could not be read: %s", + adapter.Name(), forge.Repo(), firstLine(perr.Error())) + } + if private { + return SetupResult{}, fmt.Errorf("site setup: %s is private, and %s publishes public repositories only for now", forge.Repo(), adapter.Name()) + } + } branch, branchNote := defaultBranch(ctx, root, forge) + envNames := []string{EnvRender, prof.Environment} res := SetupResult{Host: HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain, Status: HostNotReached}} if offerNote != "" { @@ -242,6 +312,62 @@ func Setup(req SetupRequest) (SetupResult, error) { if branchNote != "" { res.Notes = append(res.Notes, branchNote) } + if switchedOff { + return setupSwitchedOff(root, branch, adapter, s, project, req, res) + } + // A provider that acts through the forge is read before anything is asked + // or written, so a host it would refuse is refused before the repository + // half is written for an address the site would not be served from. + _, wferr := os.Lstat(filepath.Join(root, filepath.FromSlash(SiteWorkflowRelPath))) + noWorkflow := os.IsNotExist(wferr) + takeover := "" + if adapter.CredentialName() == "" && forge != nil && s.Repo != "" { + st, err := readForgeHost(ctx, adapter, s) + if err != nil { + return SetupResult{}, fmt.Errorf("site setup: %s", firstLine(err.Error())) + } + // A site the host already serves, in a repository setup has not + // added its workflow to, is someone's: it is published over only + // after a question naming it. + if st.Existing != "" && noWorkflow { + takeover = st.Existing + } + } + // The switch (the PT's 2026-10-14 ruling, "ask at setup"): a new + // composition naming a provider that publishes only while switched on is + // written only on a yes, and then with the switch on. A composition that + // already holds the switch but has no workflow yet (its hosting block + // added by hand, as setup's own refusal names it) is asked too: the + // workflow is what publishes, and it is added only on a yes. + askSwitch := prof.Switched && (manifestBytes != nil || noWorkflow) + // The takeover is a person's answer and never a scripted one: --yes does + // not give it (ReplaceExisting does), and a run that only --yes answers + // refuses before anything is asked or written. + if takeover != "" && !req.ReplaceExisting { + if req.Unattended { + return SetupResult{}, fmt.Errorf("site setup: this repository already publishes a %s site, at %s, and --yes does not answer "+ + "whether to publish over it; re-run at a terminal to be asked, or pass --replace-existing to replace it", adapter.Name(), takeover) + } + if !askSwitch && (req.Asker == nil || !req.Asker.Confirm(takeoverQuestion(adapter, takeover))) { + return declinedBeforeWriting(res, "the existing "+adapter.Name()+" site at "+takeover+" was not published over", + "re-run `abcd site setup` and answer yes, or pass --replace-existing, to publish over the existing site"), nil + } + } + if askSwitch { + if req.Asker == nil || !req.Asker.Confirm(switchQuestion(adapter, prof, takeover, manifestBytes == nil, project)) { + return declinedBeforeWriting(res, "the project site was not switched on", + "re-run `abcd site setup --provider "+adapter.Name()+"` and answer yes, to switch the project site on"), nil + } + } + if prof.Switched && manifestBytes != nil { + on := true + manifest.Hosting.Publish, hostingBlock.Publish = &on, &on + raw, merr := encodeSetupFile(manifest) + if merr != nil { + return SetupResult{}, merr + } + manifestBytes = raw + } // Stage 1: the repository. planned, err := plannedFiles(root, manifest, manifestBytes, branch, adapter, s) @@ -258,20 +384,25 @@ func Setup(req SetupRequest) (SetupResult, error) { } else { res.Notes = append(res.Notes, "a write failed, so no remote change was attempted: "+scrubRoot(werr, root)) } - for _, env := range []string{EnvRender, EnvDeploy} { + for _, env := range envNames { res.Environments = append(res.Environments, EnvironmentOutcome{Name: env, Status: RemoteNotReached}) } return res, nil } // The one exception to "a file the repository owns is kept" (the TG1 // ruling): a ui.json the repository already had gains each declared label - // it lacks, with its default words, and nothing in it is rewritten. - added, aerr := addMissingLabels(root, manifest.UIStrings) + // it lacks, with its default words, and nothing in it is rewritten. A + // project site has no ui.json: its words are the binary's. + var added []string + var aerr error + if !project { + added, aerr = addMissingLabels(root, manifest.UIStrings) + } if aerr != nil { res.Status = StatusRefused res.Notes = append(res.Notes, "the missing interface labels could not be added to "+manifest.UIStrings+ ", so no remote change was attempted: "+scrubRoot(aerr, root)) - for _, env := range []string{EnvRender, EnvDeploy} { + for _, env := range envNames { res.Environments = append(res.Environments, EnvironmentOutcome{Name: env, Status: RemoteNotReached}) } return res, nil @@ -298,16 +429,16 @@ func Setup(req SetupRequest) (SetupResult, error) { // 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) + "as the workflow's header describes, before setting any deploy secret", envNames[0], envNames[1], branch) if forge == nil { envOK = false - for _, env := range []string{EnvRender, EnvDeploy} { + for _, env := range envNames { 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) + envs, st, note, steps := setupEnvironments(ctx, forge, branch, envNames, req.Asker) res.Environments = envs res.Remaining = append(res.Remaining, steps...) switch st { @@ -315,7 +446,7 @@ func Setup(req SetupRequest) (SetupResult, error) { 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) + res.Remaining = append(res.Remaining, "re-run `abcd site setup` and confirm, to create the environments "+envNames[0]+" and "+envNames[1]) case RemoteRefused: refused, envOK = true, false res.Notes = append(res.Notes, note) @@ -329,7 +460,7 @@ func Setup(req SetupRequest) (SetupResult, error) { if refused { res.Host.Detail = "not reached: the forge stage failed" } else { - hc, hdeclined, hrefused := setupHost(ctx, adapter, s, req) + hc, hdeclined, hrefused := setupHost(ctx, adapter, s, forge != nil, req) res.Host = hc switch { case hrefused: @@ -355,7 +486,7 @@ func Setup(req SetupRequest) (SetupResult, error) { // 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)...) + res.Remaining = append(res.Remaining, secretSteps(ctx, forge, envOK, adapter, prof.Environment, &res)...) if wrote > 0 { var paths []string @@ -369,6 +500,12 @@ func Setup(req SetupRequest) (SetupResult, error) { } 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") + if project { + // The workflow downloads abcd's latest release to build with. Which + // release is latest is the forge's to say, and setup asks the network + // nothing for it, so the dependency is stated on every project setup. + res.Notes = append(res.Notes, projectReleaseNote) + } switch { case refused: @@ -427,7 +564,8 @@ func scrubRoot(err error, root string) string { } // setupComposition returns the composition setup works from, and the bytes to -// write when the repository has none yet (nil when it has one). +// write when the repository has none yet (nil when it has one). For a provider +// that publishes a project site, what it writes is that site's settings. 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...)) @@ -436,22 +574,36 @@ func setupComposition(root string, req SetupRequest) (Manifest, []byte, error) { switch { case err == nil: if m.Hosting == nil { - h := &Hosting{Provider: DefaultProvider, Name: req.Name, Domain: req.Domain} + h := &Hosting{Provider: providerOf(req), 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 != "" { + if a, ok := adapterNamed(h.Provider); ok && a.Profile(hosting.Site{}).Switched { + on := true + h.Publish = &on + } + if req.Domain != "" || req.Name != "" || req.Provider != "" { // 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", + // The block carries the switch on, as setup would write it, + // and the next run still asks whether to switch the project + // site on before it adds the workflow that publishes. + msg := fmt.Sprintf("site setup: %s exists and declares no hosting block; add `\"hosting\": %s` to it rather than passing --name, --domain or --provider", ManifestRelPath, blk) + if h.Publish != nil { + msg += ", then re-run `abcd site setup`, which asks whether to switch the project site on before it adds the workflow" + } + return Manifest{}, nil, errors.New(msg) } m.Hosting = h + } else if req.Provider != "" && req.Provider != m.Hosting.Provider { + return Manifest{}, nil, fmt.Errorf("site setup: %s already names the provider %q; edit its hosting block to change it", + ManifestRelPath, m.Hosting.Provider) } 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) @@ -461,6 +613,30 @@ func setupComposition(root string, req SetupRequest) (Manifest, []byte, error) { return Manifest{}, nil, err } + // A provider that publishes a project site gets the project site's + // settings and nothing else: no composition, because the site renders + // only what abcd writes (the product thinker's ruling of 2026-10-15). + a := req.Adapter + if a == nil { + a, _ = adapterNamed(providerOf(req)) + } + if a != nil && a.Profile(hosting.Site{}).Project { + name := req.Name + if name == "" { + name = defaultHostName(root) + } + m = Manifest{SchemaVersion: 1, Mode: ModeProject, Purpose: projectSettingsPurpose, + Hosting: &Hosting{Provider: providerOf(req), Name: name, Domain: req.Domain}} + if err := m.validate(); err != nil { + return Manifest{}, nil, err + } + raw, err := encodeSetupFile(m) + if err != nil { + return Manifest{}, nil, err + } + return m, raw, nil + } + cfg, ok, cerr := positioning.LoadConfig(root) if cerr != nil { return Manifest{}, nil, fmt.Errorf("site setup: the identity pointer cannot be read: %w", cerr) @@ -493,7 +669,7 @@ func setupComposition(root string, req SetupRequest) (Manifest, []byte, error) { Hero: Hero{Page: hero, Figure: figureFirstImage}, Chapters: []Chapter{{Letter: "a", Page: hero, Layout: LayoutProse}}, }, - Hosting: &Hosting{Provider: DefaultProvider, Name: name, Domain: req.Domain}, + Hosting: &Hosting{Provider: providerOf(req), Name: name, Domain: req.Domain}, } if err := m.validate(); err != nil { return Manifest{}, nil, err @@ -505,6 +681,19 @@ func setupComposition(root string, req SetupRequest) (Manifest, []byte, error) { return m, append(raw, '\n'), nil } +// encodeSetupFile renders the file setup writes at ManifestRelPath: a project +// site's settings, or a composition. +func encodeSetupFile(m Manifest) ([]byte, error) { + if m.Project() { + return encodeProjectSettings(m.Hosting) + } + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, err + } + return 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-]+`) @@ -531,6 +720,11 @@ func plannedFiles(root string, m Manifest, manifestBytes []byte, branch string, planned = append(planned, scaffold.PlannedFile{Path: ManifestRelPath, Data: manifestBytes, Seed: true}) } for _, name := range seedNames { + // A project site is seeded with nothing: its interface words, styles + // and scripts come from the binary at build time. + if m.Project() { + break + } data, err := setupSources.ReadFile("setupsrc/" + name) if err != nil { return nil, err @@ -541,13 +735,14 @@ func plannedFiles(root string, m Manifest, manifestBytes []byte, branch string, } planned = append(planned, scaffold.PlannedFile{Path: rel, Data: data, Seed: true}) } - wf, err := renderSiteWorkflow(branch, adapter) + wf, err := renderSiteWorkflow(branch, adapter, s, m.Project()) 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}) + if rel, data := adapter.HostConfig(s); rel != "" { + 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. @@ -580,9 +775,10 @@ func refuseSymlinkedAncestor(root, rel string) error { 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) { +// renderSiteWorkflow renders the workflow for the repository's default branch, +// the provider's deploy job and the site it publishes. project builds and +// gates a project site (`--project`, project.go). +func renderSiteWorkflow(branch string, adapter hosting.Adapter, s hosting.Site, project bool) ([]byte, error) { raw, err := setupSources.ReadFile("setupsrc/site.yml.tmpl") if err != nil { return nil, err @@ -592,6 +788,23 @@ func renderSiteWorkflow(branch string, adapter hosting.Adapter) ([]byte, error) return nil, err } rel, _ := adapter.HostConfig(hosting.Site{Name: "x"}) + prof := adapter.Profile(s) + ifOn, baseFlag, projectFlag := "", "", "" + if project { + projectFlag = " --project" + } + if prof.Switched { + ifOn = " if: steps.switch.outputs.on == 'true'\n" + } + base, err := NormaliseBasePath(prof.BasePath) + if err != nil { + return nil, err + } + if base != "/" { + // Plain path segments only (NormaliseBasePath), so the flag is a + // literal in the shell line and nothing in it is interpreted. + baseFlag = " --base-path " + base + } var buf bytes.Buffer err = tmpl.Execute(&buf, map[string]any{ // The branch is the forge's, held to scaffold.SafeBranchName, or @@ -603,6 +816,16 @@ func renderSiteWorkflow(branch string, adapter hosting.Adapter) ([]byte, error) "DeployStep": adapter.DeployStep(), "HostConfig": rel, "AbcdRepo": abcdReleaseRepo, + // The deploy job's environment and token permissions, the switch and + // the base path, from the provider's profile. + "DeployEnv": prof.Environment, + "DeployPermissions": prof.Permissions, + "Switched": prof.Switched, + "SwitchFile": ManifestRelPath, + "IfOn": ifOn, + "BaseFlag": baseFlag, + "Project": project, + "ProjectFlag": projectFlag, }) return buf.Bytes(), err } @@ -624,8 +847,7 @@ func desiredPolicies(branch string) []BranchPolicy { // 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} +func setupEnvironments(ctx context.Context, forge Forge, branch string, names []string, asker Asker) ([]EnvironmentOutcome, string, string, []string) { outcomes := make([]EnvironmentOutcome, len(names)) var steps []string fail := func(what string, err error) ([]EnvironmentOutcome, string, string, []string) { @@ -737,33 +959,51 @@ func containsPolicy(have []BranchPolicy, want BranchPolicy) bool { } // setupHost is the host stage. -func setupHost(ctx context.Context, adapter hosting.Adapter, s hosting.Site, req SetupRequest) (out HostOutcome, declined, refused bool) { +func setupHost(ctx context.Context, adapter hosting.Adapter, s hosting.Site, forgeReachable bool, req SetupRequest) (out HostOutcome, declined, refused bool) { out = HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain, Credential: adapter.CredentialName()} - src := req.Credentials - if src == nil { - src = credential.UserStore() - } - 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 + token := "" + if adapter.CredentialName() == "" { + // The provider acts through the forge as the person, so it needs the + // forge and resolves no credential. + if !forgeReachable || s.Repo == "" { + out.Status = HostNotReached + out.Detail = "not reached: " + adapter.Name() + " acts through the forge, which is not reachable from this checkout" + return out, false, false + } + } else { + src := req.Credentials + if src == nil { + src = credential.UserStore() + } + var err error + 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]") + out.Detail = err.Error() + if token != "" { + out.Detail = strings.ReplaceAll(out.Detail, token, "[credential]") + } return out, false, true } st, err := p.Inspect(ctx, s) if err != nil { return refuse(err) } + if st.Existing != "" { + out.Detail = "publishes to the " + adapter.Name() + " site already at " + st.Existing + } if !st.Exists { out.Changes = append(out.Changes, "create "+adapter.Name()+" host "+s.Name) } @@ -786,11 +1026,20 @@ func setupHost(ctx context.Context, adapter hosting.Adapter, s hosting.Site, req } } } - addr, err := p.Address(ctx, s) + addr, note, err := p.Address(ctx, s) if err != nil { return refuse(err) } + if err := servedAtBase(adapter, s, addr); err != nil { + return refuse(err) + } out.Address = addr + if note != "" { + if out.Detail != "" { + out.Detail += "; " + } + out.Detail += note + } out.Status = HostCurrent if len(out.Changes) > 0 { out.Status = HostWritten @@ -801,14 +1050,17 @@ func setupHost(ctx context.Context, adapter hosting.Adapter, s hosting.Site, req // 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 { +func secretSteps(ctx context.Context, forge Forge, envOK bool, adapter hosting.Adapter, env string, res *SetupResult) []string { want := adapter.Secrets() + if len(want) == 0 { + return nil + } missing := want repo := res.Repo if forge != nil && envOK { - names, err := forge.SecretNames(ctx, EnvDeploy) + names, err := forge.SecretNames(ctx, env) 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()) + res.Notes = append(res.Notes, "the "+env+" environment's secret names could not be read, so every one is listed: "+err.Error()) } else { have := map[string]bool{} for _, n := range names { @@ -825,7 +1077,7 @@ func secretSteps(ctx context.Context, forge Forge, envOK bool, adapter hosting.A sort.Strings(missing) var steps []string for _, name := range missing { - cmd := "gh secret set " + name + " --env " + EnvDeploy + cmd := "gh secret set " + name + " --env " + env if repo != "" { cmd += " --repo " + repo } @@ -833,3 +1085,167 @@ func secretSteps(ctx context.Context, forge Forge, envOK bool, adapter hosting.A } return steps } + +// providerOf is the provider a new hosting block names. +func providerOf(req SetupRequest) string { + if req.Provider != "" { + return req.Provider + } + return DefaultProvider +} + +// firstLine keeps a forge error to its first line, so a refusal stays one line. +func firstLine(s string) string { + line, _, _ := strings.Cut(s, "\n") + return line +} + +// switchQuestion is the switch, asked once, before anything is written. A +// site the host already serves (takeover, its address and build type) is +// named in it, since a yes publishes over that site. +// +// recorded says the composition already holds the switch, written by hand, so +// a yes leaves it as it is and adds what publishes. +func switchQuestion(adapter hosting.Adapter, prof hosting.Profile, takeover string, recorded, project bool) string { + where := adapter.Name() + if prof.Address != "" { + where = prof.Address + " (" + adapter.Name() + ")" + } + yes := "Yes records the switch in " + ManifestRelPath + ", writes the site's inputs under site-src/" + if recorded { + yes = ManifestRelPath + " already holds the switch; yes leaves it as it is, writes the site's inputs under site-src/" + } + built := "" + if project { + // A project site writes no site inputs: its pages come from what abcd + // already writes, and its words, styles and scripts from the binary. + built = ", built from what abcd already writes to the repository (its identity block, brief, record and changelog)" + yes = "Yes records the switch in " + ManifestRelPath + if recorded { + yes = ManifestRelPath + " already holds the switch; yes leaves it as it is" + } + } + q := "Switch on the project site? At each release this repository's own workflow builds its about page and development record" + built + + " and publishes them at " + where + ". " + yes + " " + + "and adds " + SiteWorkflowRelPath + "; nothing else in the repository changes" + if takeover != "" { + q += ". This repository already publishes a site there, at " + takeover + ", and yes replaces it at the next release" + } + return q +} + +// takeoverQuestion asks before publishing over a site the host already +// serves, when no switch question is asked to name it. +func takeoverQuestion(adapter hosting.Adapter, takeover string) string { + return "Publish over the existing " + adapter.Name() + " site? This repository already publishes one, at " + takeover + + ", and abcd's release workflow (" + SiteWorkflowRelPath + ") replaces it at the next release" +} + +// declinedBeforeWriting is a run declined before anything was written or +// changed remotely: why, and the step that takes it further. +func declinedBeforeWriting(res SetupResult, why, step string) SetupResult { + res.Status = StatusDeclined + res.Files, res.Environments = []scaffold.FileOutcome{}, []EnvironmentOutcome{} + res.Host.Detail = "not reached: " + why + res.Notes = append(res.Notes, why+", so nothing was written and nothing remote was changed") + res.Remaining = append(res.Remaining, step) + return res +} + +// setupSwitchedOff is setup for a project site whose switch is off: the next +// release publishes nothing whatever happens here, and setup offers to remove +// the release workflow it added. A workflow that differs from the one setup +// writes is a person's, and is kept. Nothing remote is touched. +func setupSwitchedOff(root, branch string, adapter hosting.Adapter, s hosting.Site, project bool, req SetupRequest, res SetupResult) (SetupResult, error) { + res.Files, res.Environments = []scaffold.FileOutcome{}, []EnvironmentOutcome{} + res.Host.Detail = "not reached: the project site is switched off" + res.Notes = append(res.Notes, "the project site is switched off in "+ManifestRelPath+" (hosting.publish is not true), so the next release "+ + "publishes nothing; pages already published stay up until they are unpublished in the repository's settings") + res.Status = StatusNoChange + want, err := renderSiteWorkflow(branch, adapter, s, project) + if err != nil { + return SetupResult{}, err + } + if err := refuseSymlinkedAncestor(root, SiteWorkflowRelPath); err != nil { + return SetupResult{}, err + } + path := filepath.Join(root, filepath.FromSlash(SiteWorkflowRelPath)) + fi, err := os.Lstat(path) + switch { + case os.IsNotExist(err): + return res, nil + case err != nil: + return SetupResult{}, fmt.Errorf("site setup: %s could not be examined", SiteWorkflowRelPath) + case !fi.Mode().IsRegular(): + res.Files = append(res.Files, scaffold.FileOutcome{Path: SiteWorkflowRelPath, Status: scaffold.StatusKept, + Detail: "not a regular file, so setup leaves it"}) + return res, nil + } + have, err := os.ReadFile(path) + if err != nil { + return SetupResult{}, fmt.Errorf("site setup: %s could not be read", SiteWorkflowRelPath) + } + if !bytes.Equal(have, want) { + res.Files = append(res.Files, scaffold.FileOutcome{Path: SiteWorkflowRelPath, Status: scaffold.StatusKept, + Detail: "differs from the workflow setup writes, so it was edited by hand and setup leaves it; remove it yourself if it is no longer wanted"}) + return res, nil + } + if req.Asker == nil || !req.Asker.Confirm("Remove "+SiteWorkflowRelPath+", the release workflow abcd added? The project site is switched off in "+ + ManifestRelPath+", so it has nothing to publish") { + res.Files = append(res.Files, scaffold.FileOutcome{Path: SiteWorkflowRelPath, Status: scaffold.StatusKept, Detail: "its removal was declined"}) + res.Status = StatusDeclined + res.Remaining = append(res.Remaining, "re-run `abcd site setup` and confirm, to remove "+SiteWorkflowRelPath) + return res, nil + } + if err := os.Remove(path); err != nil { + res.Status = StatusRefused + res.Files = append(res.Files, scaffold.FileOutcome{Path: SiteWorkflowRelPath, Status: scaffold.StatusRefused, Detail: "the removal failed"}) + return res, nil + } + res.Files = append(res.Files, scaffold.FileOutcome{Path: SiteWorkflowRelPath, Status: FileRemoved}) + res.Status = StatusChanged + res.Remaining = append(res.Remaining, "commit the removal and push it to "+branch+": `git add "+SiteWorkflowRelPath+"`") + return res, nil +} + +// readForgeHost is the host's read, made before anything is asked or written +// for a provider that acts through the forge: Inspect, and for a host that +// already exists with nothing left to route, the path it serves the site +// below. A host it would refuse later is refused here. +func readForgeHost(ctx context.Context, adapter hosting.Adapter, s hosting.Site) (hosting.State, error) { + p := adapter.Connect("") + st, err := p.Inspect(ctx, s) + if err != nil || !st.Exists || !st.Routed { + // A host still to be routed changes its address on Route, so its path + // is judged by the host stage after the route. + return st, err + } + addr, _, err := p.Address(ctx, s) + if err != nil { + return st, err + } + return st, servedAtBase(adapter, s, addr) +} + +// servedAtBase refuses an address the host serves the site below a path other +// than the one the workflow builds it for: every root-absolute link the build +// writes would miss the site. +func servedAtBase(adapter hosting.Adapter, s hosting.Site, addr string) error { + want, err := NormaliseBasePath(adapter.Profile(s).BasePath) + if err != nil { + return err + } + u, err := url.Parse(addr) + if err != nil { + return fmt.Errorf("%s reports an address that is not a URL", adapter.Name()) + } + got := "/" + strings.Trim(u.Path, "/") + "/" + if got == "//" { + got = "/" + } + if got != want { + return fmt.Errorf("%s serves this site at %s, below %s, but the workflow builds it for %s; "+ + "abcd will not publish a site whose every link would miss it", adapter.Name(), addr, got, want) + } + return nil +} diff --git a/internal/core/site/setup_pages_switch_test.go b/internal/core/site/setup_pages_switch_test.go new file mode 100644 index 000000000..518d487a6 --- /dev/null +++ b/internal/core/site/setup_pages_switch_test.go @@ -0,0 +1,164 @@ +package site + +// The project-site switch as the release workflow reads it +// (itd-2610091010348892): the switch step setup writes is run with bash in a +// checkout shaped the way actions/checkout leaves one (the released tag +// checked out, fetch-depth 0, so every branch is there as origin/), +// and its output decides whether the deploy job runs at all. Nothing here +// reaches a network. + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/githubpages" + "github.com/intentdriven/abcd/internal/gittest" +) + +// switchStep is the run block of the workflow's switch step, unindented. +func switchStep(t *testing.T, wf string) string { + t.Helper() + lines := strings.Split(wf, "\n") + start := -1 + for i, l := range lines { + if strings.TrimSpace(l) == "id: switch" { + start = i + break + } + } + if start < 0 { + t.Fatal("the workflow has no switch step") + } + var script []string + runIndent := -1 + for _, l := range lines[start+1:] { + indent := len(l) - len(strings.TrimLeft(l, " ")) + if runIndent < 0 { + if strings.TrimSpace(l) == "run: |" { + runIndent = indent + } + continue + } + if strings.TrimSpace(l) != "" && indent <= runIndent { + break + } + script = append(script, strings.TrimPrefix(l, strings.Repeat(" ", runIndent+2))) + } + if len(script) == 0 { + t.Fatal("the switch step has no run block") + } + return strings.Join(script, "\n") + "\n" +} + +func TestTheSwitchIsReadAtTheTagAndTheDefaultBranchAndOnlyWhilePublic(t *testing.T) { + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq is not installed; the workflow's runner carries it") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not installed") + } + wf, err := renderSiteWorkflow("trunk", githubpages.Adapter{}, hosting.Site{Name: "widgets", Repo: "acme/widgets"}, true) + if err != nil { + t.Fatal(err) + } + script := switchStep(t, string(wf)) + + const ( + on = `{"hosting": {"provider": "github-pages", "name": "widgets", "publish": true}}` + off = `{"hosting": {"provider": "github-pages", "name": "widgets", "publish": false}}` + malformed = `{"hosting": {"publish": true` + missing = "" + unset = "" + ) + for name, c := range map[string]struct { + atTag, atHead, private, want string + }{ + "on at the tag and the head, public": {on, on, "false", "true"}, + "switched off on the default branch since": {on, off, "false", "false"}, + "no switch file on the default branch": {on, missing, "false", "false"}, + "a malformed switch file on the branch": {on, malformed, "false", "false"}, + "off at the tag, on at the head": {off, on, "false", "false"}, + "made private since setup": {on, on, "true", "false"}, + "a visibility the event does not carry": {on, on, "", "false"}, + "a visibility the step was handed no value of": {on, on, unset, "false"}, + } { + t.Run(name, func(t *testing.T) { + origin := gittest.NewRepo(t) + origin.Git("checkout", "-q", "-b", "trunk") + origin.Write(ManifestRelPath, c.atTag) + origin.Commit("the released commit") + origin.Git("tag", "v1.0.0") + if c.atHead == missing { + origin.Remove(ManifestRelPath) + } else { + origin.Write(ManifestRelPath, c.atHead) + } + origin.Commit("the default branch since the release") + + clone := filepath.Join(t.TempDir(), "checkout") + origin.Git("clone", "-q", "--no-checkout", origin.Root(), clone) + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", clone, "-c", "gc.auto=0", "-c", "maintenance.auto=false"}, args...)...) + cmd.Env = origin.Env() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + git("checkout", "-q", "--detach", "v1.0.0") + + output := filepath.Join(t.TempDir(), "github_output") + if err := os.WriteFile(output, nil, 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", "-c", script) + cmd.Dir = clone + cmd.Env = append(origin.Env(), "GITHUB_OUTPUT="+output) + if c.private != unset { + cmd.Env = append(cmd.Env, "REPO_PRIVATE="+c.private) + } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("the switch step failed: %v\n%s", err, out) + } + raw, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(raw)); got != "on="+c.want { + t.Fatalf("the switch step wrote %q, want on=%s", got, c.want) + } + }) + } +} + +// TestTheDeployJobRunsOnlyForAPublicRepository pins the deploy job's gate: +// the switch the render job read, and the repository's visibility as the +// event carries it, compared as JSON so a missing field never reads as public. +func TestTheDeployJobRunsOnlyForAPublicRepository(t *testing.T) { + wf, err := renderSiteWorkflow("main", githubpages.Adapter{}, hosting.Site{Name: "widgets", Repo: "acme/widgets"}, true) + if err != nil { + t.Fatal(err) + } + gate := regexp.MustCompile(`(?m)^ deploy:\n needs: render\n if: needs\.render\.outputs\.publish == 'true' && toJSON\(github\.event\.repository\.private\) == 'false'\n`) + if !gate.Match(wf) { + t.Error("the deploy job is not gated on the switch and on a public repository") + } + if !strings.Contains(string(wf), " REPO_PRIVATE: ${{ github.event.repository.private }}\n") { + t.Error("the switch step is not handed the repository's visibility") + } +} + +// TestThePagesWorkflowIsPinned pins the workflow setup writes for the +// github-pages provider byte for byte. +func TestThePagesWorkflowIsPinned(t *testing.T) { + wf, err := renderSiteWorkflow("main", githubpages.Adapter{}, hosting.Site{Name: "widgets", Repo: "acme/widgets"}, true) + if err != nil { + t.Fatal(err) + } + golden(t, "site-workflow-github-pages.yml", wf) +} diff --git a/internal/core/site/setup_pages_takeover_test.go b/internal/core/site/setup_pages_takeover_test.go new file mode 100644 index 000000000..cdd868374 --- /dev/null +++ b/internal/core/site/setup_pages_takeover_test.go @@ -0,0 +1,196 @@ +package site + +// Publishing over a Pages site that is already live, and adding the hosting +// block to a composition that lacks one (itd-2610091010348892). The forge is +// the in-process fake, the Pages API githubpagestest's. Nothing here reaches a +// network. + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/githubpages" + "github.com/intentdriven/abcd/internal/adapter/hosting/githubpages/githubpagestest" +) + +const existingPages = "https://example-owner.github.io/example-site/ (build type workflow)" + +// A run answered in advance (--yes) never answers the takeover: with a live +// site in the repository it refuses before anything is asked or written, and +// names the site, unless --replace-existing was passed. +func TestAnUnattendedRunNeverPublishesOverALiveSite(t *testing.T) { + t.Run("refused without --replace-existing", func(t *testing.T) { + h := newPagesHarness(t) + h.pages.Repos["example-owner/example-site"].Pages = &githubpagestest.Pages{BuildType: "workflow"} + req := pagesRequest(t, h) + req.Unattended = true + _, err := Setup(req) + refusedBeforeAnything(t, h, err, existingPages) + if !strings.Contains(err.Error(), "--replace-existing") { + t.Fatalf("the refusal does not name the flag that lets it through: %v", err) + } + }) + t.Run("refused when the composition is already switched on", func(t *testing.T) { + h := newPagesHarness(t) + h.run(t) + h.repo.Git("add", "-A") + h.repo.Git("commit", "-q", "-m", "switch the project site on") + if err := os.Remove(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); err != nil { + t.Fatal(err) + } + h.ask.questions = nil + writes, forgeWrites := len(pagesWrites(h)), len(h.forge.writes) + req := pagesRequest(t, h) + req.Unattended = true + _, err := Setup(req) + if err == nil || !strings.Contains(err.Error(), existingPages) || !strings.Contains(err.Error(), "--replace-existing") { + t.Fatalf("setup = %v, want a refusal naming %q and the flag", err, existingPages) + } + if len(pagesWrites(h)) != writes || len(h.forge.writes) != forgeWrites || len(h.ask.questions) != 0 { + t.Fatalf("the refused run wrote %v %v or asked %q", pagesWrites(h)[writes:], h.forge.writes[forgeWrites:], h.ask.questions) + } + if _, err := os.Stat(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); !os.IsNotExist(err) { + t.Fatal("the refused run wrote the workflow") + } + }) + t.Run("published over with --replace-existing", func(t *testing.T) { + h := newPagesHarness(t) + h.pages.Repos["example-owner/example-site"].Pages = &githubpagestest.Pages{BuildType: "workflow"} + res := h.run(t, func(r *SetupRequest) { r.Unattended, r.ReplaceExisting = true, true }) + if res.Status == StatusRefused || res.Status == StatusDeclined { + t.Fatalf("status = %q, notes %v", res.Status, res.Notes) + } + if _, err := os.Stat(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); err != nil { + t.Fatal("the workflow was not written") + } + if res.Host.Status != HostCurrent || !strings.Contains(res.Host.Detail, existingPages) { + t.Fatalf("host = %+v, want the outcome naming %q", res.Host, existingPages) + } + }) + // A person at a terminal who passes the flag has answered the takeover + // already, so only the switch is asked. + t.Run("--replace-existing answers the takeover", func(t *testing.T) { + h := newPagesHarness(t) + h.run(t) + h.repo.Git("add", "-A") + h.repo.Git("commit", "-q", "-m", "switch the project site on") + if err := os.Remove(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); err != nil { + t.Fatal(err) + } + h.ask.questions = nil + res := h.run(t, func(r *SetupRequest) { r.ReplaceExisting = true }) + for _, q := range h.ask.questions { + if strings.HasPrefix(q, "Publish over") { + t.Fatalf("the takeover was asked although --replace-existing answered it: %q", q) + } + } + if res.Status == StatusRefused || res.Status == StatusDeclined { + t.Fatalf("status = %q, notes %v", res.Status, res.Notes) + } + }) +} + +// GitHub reports a custom domain over http until its certificate is issued; +// the host stage reports the site live at its https address and says HTTPS is +// not enforced yet. +func TestTheHostStageSaysWhenHTTPSIsNotEnforcedYet(t *testing.T) { + f := githubpagestest.New() + f.Repos["acme/widgets"].Pages = &githubpagestest.Pages{BuildType: "workflow", CNAME: "docs.example.com", HTMLURL: "http://docs.example.com/"} + out, declined, refused := setupHost(context.Background(), githubpages.Adapter{API: f.API}, + hosting.Site{Name: "widgets", Repo: "acme/widgets", Domain: "docs.example.com"}, true, SetupRequest{Asker: answer(true)}) + if refused || declined || out.Status != HostCurrent { + t.Fatalf("host = %+v (declined %v, refused %v), want current", out, declined, refused) + } + if out.Address != "https://docs.example.com/" || !strings.Contains(out.Detail, "HTTPS is not enforced yet") { + t.Fatalf("host = %+v, want the https address and the note", out) + } +} + +// hostingBlockRe finds the block the refusal tells the person to add. +var hostingBlockRe = regexp.MustCompile("`\"hosting\": (\\{[^`]*\\})`") + +// A composition with no hosting block is not rewritten; setup names the block +// to add. A block added that way, switch on, is still put to the person as the +// switch question before the workflow is written: the switch is asked at setup +// whoever wrote it. (A composition is the composed route, a repository's own +// declaration; setup itself writes a project site's settings instead.) +func TestAHostingBlockAddedByHandIsStillAskedTheSwitch(t *testing.T) { + h := newPagesHarness(t) + // A repository with a composition but no hosting block, no workflow and + // no Pages site. + m := map[string]any{ + "schema_version": 1, + "purpose": "A composition written by hand.", + "identity": map[string]any{"file": ".abcd/development/IDENTITY.md", "heading": "Identity (canonical)"}, + "ui_strings": "site-src/ui.json", + "home": map[string]any{ + "hero": map[string]any{"page": "docs/README.md", "figure": "first-image"}, + "chapters": []any{map[string]any{"letter": "a", "page": "docs/README.md", "layout": "prose"}}, + }, + } + raw, _ := json.MarshalIndent(m, "", " ") + h.repo.Write(ManifestRelPath, string(raw)+"\n") + h.repo.Git("add", "-A") + h.repo.Git("commit", "-q", "-m", "a composition with no hosting block") + + _, err := Setup(pagesRequest(t, h)) + if err == nil { + t.Fatal("setup accepted --provider on a composition with no hosting block") + } + got := hostingBlockRe.FindStringSubmatch(err.Error()) + if got == nil || !strings.Contains(err.Error(), "asks whether to switch the project site on") { + t.Fatalf("the refusal names no block to add, or does not say the switch is still asked: %v", err) + } + var blk map[string]any + if jerr := json.Unmarshal([]byte(got[1]), &blk); jerr != nil { + t.Fatalf("the block is not JSON: %v", jerr) + } + m["hosting"] = blk + raw, _ = json.MarshalIndent(m, "", " ") + if err := os.WriteFile(filepath.Join(h.repo.Root(), ManifestRelPath), append(raw, '\n'), 0o644); err != nil { + t.Fatal(err) + } + h.repo.Git("add", "-A") + h.repo.Git("commit", "-q", "-m", "the hosting block, as setup named it") + + h.ask.questions, h.ask.yes = nil, false + res := h.harness.run(t, func(r *SetupRequest) { + r.Adapter = githubpages.Adapter{API: h.pages.API} + r.Credentials = noCredential{t} + r.Asker = h.ask + }) + if len(h.ask.questions) != 1 || !strings.HasPrefix(h.ask.questions[0], "Switch on the project site?") { + t.Fatalf("questions = %q, want the switch alone", h.ask.questions) + } + if res.Status != StatusDeclined { + t.Fatalf("status = %q, want declined", res.Status) + } + if _, err := os.Stat(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); !os.IsNotExist(err) { + t.Fatal("a declined switch wrote the workflow") + } + if len(changedPaths(t, h.harness)) != 0 { + t.Fatalf("a declined switch changed %v", changedPaths(t, h.harness)) + } + + h.ask.questions, h.ask.yes = nil, true + res = h.harness.run(t, func(r *SetupRequest) { + r.Adapter = githubpages.Adapter{API: h.pages.API} + r.Credentials = noCredential{t} + r.Asker = h.ask + }) + if _, err := os.Stat(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); err != nil || res.Status != StatusChanged { + t.Fatalf("a yes did not write the workflow (status %q): %v", res.Status, err) + } + // The composition the person wrote is left as they wrote it. + for _, p := range changedPaths(t, h.harness) { + if p == ManifestRelPath { + t.Fatal("setup rewrote the composition the person wrote") + } + } +} diff --git a/internal/core/site/setup_pages_test.go b/internal/core/site/setup_pages_test.go new file mode 100644 index 000000000..b9cf614f4 --- /dev/null +++ b/internal/core/site/setup_pages_test.go @@ -0,0 +1,504 @@ +package site + +// `abcd site setup --provider github-pages` (itd-2610091010348892): the switch +// asked at setup, the release workflow added and nothing else, public +// repositories only, and a switched-off site that publishes nothing. The forge +// is the in-process fake, the Pages API githubpagestest's. 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/githubpages" + "github.com/intentdriven/abcd/internal/adapter/hosting/githubpages/githubpagestest" + "github.com/intentdriven/abcd/internal/core/launch/scaffold" +) + +// asked answers every question the same way and records each one. +type asked struct { + yes bool + questions []string +} + +func (a *asked) Confirm(q string) bool { a.questions = append(a.questions, q); return a.yes } + +// noCredential fails the test if anything resolves a credential: the Pages +// provider acts through the forge and reads none. +type noCredential struct{ t *testing.T } + +func (c noCredential) Resolve(name string) (string, error) { + c.t.Errorf("the credential %q was resolved; the github-pages provider reads none", name) + return "", errors.New("no") +} + +type pagesHarness struct { + *harness + pages *githubpagestest.Server + ask *asked +} + +func newPagesHarness(t *testing.T) *pagesHarness { + t.Helper() + h := &pagesHarness{harness: newHarness(t), pages: githubpagestest.New(), ask: &asked{yes: true}} + h.pages.Repos = map[string]*githubpagestest.Repo{"example-owner/example-site": {}} + return h +} + +func (h *pagesHarness) run(t *testing.T, mutate ...func(*SetupRequest)) SetupResult { + t.Helper() + return h.harness.run(t, append([]func(*SetupRequest){func(r *SetupRequest) { + r.Provider = "github-pages" + r.Adapter = githubpages.Adapter{API: h.pages.API} + r.Credentials = noCredential{t} + r.Asker = h.ask + }}, mutate...)...) +} + +// pagesFiles is the whole repository half for the github-pages provider: the +// project site's settings, holding the switch, and the release workflow. +// Nothing is written for the website (the product thinker's ruling of +// 2026-10-15): no composition and no site sources. +var pagesFiles = []string{ManifestRelPath, SiteWorkflowRelPath} + +func changedPaths(t *testing.T, h *harness) []string { + t.Helper() + var changed []string + for _, line := range strings.Split(strings.TrimRight(h.repo.Git("status", "--porcelain", "-uall"), "\n"), "\n") { + if line != "" { + changed = append(changed, strings.TrimSpace(line[2:])) + } + } + sort.Strings(changed) + return changed +} + +func TestSwitchingOnAProjectSiteSetsTheSwitchAndAddsTheWorkflowOnly(t *testing.T) { + h := newPagesHarness(t) + res := h.run(t) + + if len(h.ask.questions) == 0 || !strings.Contains(h.ask.questions[0], "github-pages") || + !strings.Contains(h.ask.questions[0], "https://example-owner.github.io/example-site/") { + t.Fatalf("the first question is not the switch naming the address: %q", h.ask.questions) + } + want := append([]string(nil), pagesFiles...) + sort.Strings(want) + if got := changedPaths(t, h.harness); !reflect.DeepEqual(got, want) { + t.Fatalf("the repository changed in\n %v\nwant exactly\n %v", got, want) + } + m, err := LoadManifest(h.repo.Root()) + if err != nil { + t.Fatal(err) + } + if m.Hosting == nil || m.Hosting.Provider != "github-pages" || !m.Hosting.On() { + t.Fatalf("hosting = %+v, want github-pages switched on", m.Hosting) + } + settings := string(mustRead(t, h.repo.Root(), ManifestRelPath)) + if !strings.Contains(settings, `"publish": true`) { + t.Fatal("the settings do not carry the switch") + } + if !m.Project() { + t.Fatalf("setup wrote a composition, not a project site's settings:\n%s", settings) + } + for _, key := range []string{`"identity"`, `"home"`, `"ui_strings"`, `"docs"`, `"chapters"`} { + if strings.Contains(settings, key) { + t.Errorf("the settings carry the composition key %s:\n%s", key, settings) + } + } + + wf := string(mustRead(t, h.repo.Root(), SiteWorkflowRelPath)) + for _, s := range []string{"environment: github-pages\n", " pages: write\n", " id-token: write\n", + "--base-path /example-site/", "jq -e '.hosting.publish == true' .abcd/site.json", + "site build --project --out site", "lint site --project --out site"} { + if !strings.Contains(wf, s) { + t.Errorf("the workflow lacks %q", s) + } + } + + for _, env := range []string{EnvRender, githubpages.Environment} { + pol := h.forge.policies[env] + if !reflect.DeepEqual(pol, []BranchPolicy{{Name: "main", Type: "branch"}, {Name: "v*", Type: "tag"}}) { + t.Errorf("environment %s policies = %v", env, pol) + } + } + if _, ok := h.forge.envs[EnvDeploy]; ok { + t.Errorf("the cloudflare deploy environment %q was created for a Pages site", EnvDeploy) + } + if res.Host.Status != HostWritten || res.Host.Address != "https://example-owner.github.io/example-site/" { + t.Fatalf("host = %+v", res.Host) + } + if p := h.pages.Repos["example-owner/example-site"].Pages; p == nil || p.BuildType != "workflow" { + t.Fatalf("the Pages site = %+v, want one built by the workflow", p) + } + remaining := strings.Join(res.Remaining, "\n") + if strings.Contains(remaining, "gh secret set") || !strings.Contains(remaining, "git add") { + t.Errorf("remaining steps:\n%s", remaining) + } + if res.Status != StatusChanged { + t.Fatalf("status = %q", res.Status) + } + + // The project site builds below the project's path from the settings + // alone, and the gate passes it. + out := t.TempDir() + if _, err := Build(Request{RepoRoot: h.repo.Root(), OutDir: out, Stamp: fixtureStamp, BasePath: "/example-site/", Project: true}); err != nil { + t.Fatalf("the switched-on project site does not build: %v", err) + } + if res, err := Check(CheckRequest{RepoRoot: h.repo.Root(), OutDir: out, BasePath: "/example-site/", Project: true}); err != nil { + t.Fatal(err) + } else if !res.OK() { + t.Errorf("the project site fails its gate: %+v", res.Findings) + } +} + +// A repository holding nothing abcd writes that a project site is built from +// is refused before anything is asked or written: the site would be empty. +func TestAProjectSiteWithNothingAbcdWroteIsRefused(t *testing.T) { + h := newPagesHarness(t) + h.repo.Git("rm", "-q", "-r", ".abcd/positioning.json", ".abcd/development/IDENTITY.md") + h.repo.Write("README.md", "# Example\n\nA person's own words.\n") + h.repo.Commit("nothing abcd wrote but the record's scaffolding") + _, err := Setup(pagesRequest(t, h)) + refusedBeforeAnything(t, h, err, "the site would be empty") +} + +func TestDecliningTheSwitchChangesNothing(t *testing.T) { + h := newPagesHarness(t) + h.ask.yes = false + res := h.run(t) + if res.Status != StatusDeclined { + t.Fatalf("status = %q, want declined", res.Status) + } + if got := changedPaths(t, h.harness); len(got) != 0 { + t.Fatalf("a declined switch changed %v", got) + } + // The host is read before the question (the seam's one call before a + // confirmation); nothing is written. + if len(h.forge.writes) != 0 || len(pagesWrites(h)) != 0 { + t.Fatalf("a declined switch wrote to the forge: %v %v", h.forge.writes, pagesWrites(h)) + } + if len(h.ask.questions) != 1 { + t.Fatalf("questions = %q, want the switch alone", h.ask.questions) + } + if !strings.Contains(strings.Join(res.Remaining, "\n"), "--provider github-pages") { + t.Fatalf("the remaining steps do not say how to switch it on: %v", res.Remaining) + } +} + +func TestAPrivateRepositoryIsRefusedBeforeAnything(t *testing.T) { + h := newPagesHarness(t) + h.forge.private = true + _, err := Setup(SetupRequest{ + RepoRoot: h.repo.Root(), Provider: "github-pages", Asker: h.ask, Forge: h.forge, + Adapter: githubpages.Adapter{API: h.pages.API}, Credentials: noCredential{t}, Context: context.Background(), + }) + if err == nil || !strings.Contains(err.Error(), "private") || !strings.Contains(err.Error(), "public repositories only") || + strings.Contains(err.Error(), "\n") { + t.Fatalf("setup = %v, want a one-line refusal of a private repository", err) + } + if len(h.ask.questions) != 0 || len(changedPaths(t, h.harness)) != 0 || len(h.pages.CallLog()) != 0 { + t.Fatalf("a private repository was asked %q, changed %v, called %v", h.ask.questions, changedPaths(t, h.harness), h.pages.CallLog()) + } +} + +func TestAVisibilityTheForgeCannotReadIsRefused(t *testing.T) { + h := newPagesHarness(t) + h.forge.fail["Private"] = errors.New("HTTP 502") + _, err := Setup(SetupRequest{ + RepoRoot: h.repo.Root(), Provider: "github-pages", Asker: h.ask, Forge: h.forge, + Adapter: githubpages.Adapter{API: h.pages.API}, Credentials: noCredential{t}, Context: context.Background(), + }) + if err == nil || !strings.Contains(err.Error(), "public") { + t.Fatalf("setup = %v, want a refusal", err) + } + if len(changedPaths(t, h.harness)) != 0 { + t.Fatal("an unreadable visibility still wrote files") + } +} + +// switchOff commits the switched-on files and turns the switch off. +func switchOff(t *testing.T, h *pagesHarness) { + t.Helper() + h.repo.Git("add", "-A") + h.repo.Git("commit", "-q", "-m", "switch the project site on") + raw := strings.Replace(string(mustRead(t, h.repo.Root(), ManifestRelPath)), `"publish": true`, `"publish": false`, 1) + if err := os.WriteFile(filepath.Join(h.repo.Root(), ManifestRelPath), []byte(raw), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestASwitchedOffSiteRemovesTheWorkflowAndPublishesNothing(t *testing.T) { + h := newPagesHarness(t) + h.run(t) + switchOff(t, h) + forgeWrites, pagesCalls := len(h.forge.writes), len(h.pages.CallLog()) + h.ask.questions = nil + + res := h.run(t) + if _, err := os.Stat(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); !os.IsNotExist(err) { + t.Fatalf("the workflow is still there (%v)", err) + } + if got := fileStatuses(res)[SiteWorkflowRelPath]; got != string(FileRemoved) { + t.Fatalf("workflow outcome = %q, want removed", got) + } + if len(h.ask.questions) != 1 || !strings.Contains(h.ask.questions[0], SiteWorkflowRelPath) { + t.Fatalf("questions = %q, want the removal alone", h.ask.questions) + } + if len(h.forge.writes) != forgeWrites || len(h.pages.CallLog()) != pagesCalls { + t.Fatalf("a switched-off run reached the forge: %v %v", h.forge.writes[forgeWrites:], h.pages.CallLog()[pagesCalls:]) + } + if !strings.Contains(strings.Join(res.Notes, "\n"), "publishes nothing") { + t.Fatalf("notes do not say the next release publishes nothing: %v", res.Notes) + } + if res.Status != StatusChanged { + t.Fatalf("status = %q", res.Status) + } + // Nothing else in the repository moved. + if got := changedPaths(t, h.harness); !reflect.DeepEqual(got, []string{ManifestRelPath, SiteWorkflowRelPath}) { + t.Fatalf("changed %v, want the switch and the removed workflow", got) + } + + // A third run has nothing left to do. + h.ask.questions = nil + if res = h.run(t); res.Status != StatusNoChange || len(h.ask.questions) != 0 { + t.Fatalf("third run = %q, asked %q", res.Status, h.ask.questions) + } +} + +func TestASwitchedOffSiteKeepsAWorkflowEditedByHand(t *testing.T) { + h := newPagesHarness(t) + h.run(t) + switchOff(t, h) + wf := filepath.Join(h.repo.Root(), SiteWorkflowRelPath) + if err := os.WriteFile(wf, append(mustRead(t, h.repo.Root(), SiteWorkflowRelPath), "# mine\n"...), 0o644); err != nil { + t.Fatal(err) + } + h.ask.questions = nil + res := h.run(t) + if _, err := os.Stat(wf); err != nil { + t.Fatal("a hand-edited workflow was removed") + } + if got := fileStatuses(res)[SiteWorkflowRelPath]; got != string(scaffold.StatusKept) { + t.Fatalf("workflow outcome = %q, want kept", got) + } + if len(h.ask.questions) != 0 { + t.Fatalf("asked %q about a workflow it will not remove", h.ask.questions) + } +} + +func TestDecliningTheRemovalKeepsTheWorkflow(t *testing.T) { + h := newPagesHarness(t) + h.run(t) + switchOff(t, h) + h.ask.yes = false + res := h.run(t) + if _, err := os.Stat(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); err != nil { + t.Fatal("a declined removal removed the workflow") + } + if res.Status != StatusDeclined { + t.Fatalf("status = %q, want declined", res.Status) + } +} + +func TestThePagesWorkflowHoldsNoSecretAndInterpolatesNothingIntoAShell(t *testing.T) { + wf, err := renderSiteWorkflow("main", githubpages.Adapter{}, hosting.Site{Name: "widgets", Repo: "acme/widgets"}, true) + if err != nil { + t.Fatal(err) + } + s := string(wf) + if strings.Contains(s, "{{ secrets.") { + t.Error("the Pages workflow reads a secret") + } + if strings.Contains(s, "for the host configuration") { + t.Error("the Pages deploy job checks out the repository, which it reads nothing from") + } + if !regexp.MustCompile(`(?m)^ deploy:\n needs: render\n if: needs.render.outputs.publish == 'true' && `).MatchString(s) { + t.Error("the deploy job is not gated on the switch") + } + if got := strings.Count(s, " if: steps.switch.outputs.on == 'true'\n"); got != 5 { + t.Errorf("%d render steps are gated on the switch, want the five after it", got) + } + if !strings.Contains(s, " permissions:\n contents: read\n pages: write\n id-token: write\n steps:\n") { + t.Error("the deploy job's permissions are not exactly the Pages three") + } + inRun, runIndent := false, 0 + for i, line := range strings.Split(s, "\n") { + indent := len(line) - len(strings.TrimLeft(line, " ")) + trimmed := strings.TrimSpace(line) + if inRun && trimmed != "" && indent <= runIndent { + inRun = false + } + if 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) + } + } +} + +func TestThePublishSwitchBelongsToASwitchedProvider(t *testing.T) { + on := true + bad := func(format string, args ...any) error { return errors.New(format) } + if err := (&Hosting{Provider: "cloudflare", Name: "x", Publish: &on}).validate(bad); err == nil { + t.Fatal("a cloudflare hosting block accepted the publish switch, which its workflow never reads") + } + if err := (&Hosting{Provider: "github-pages", Name: "x", Publish: &on}).validate(bad); err != nil { + t.Fatalf("github-pages refused its own switch: %v", err) + } +} + +// pagesRequest is the setup request the Pages harness makes, for a test that +// expects Setup itself to refuse. +func pagesRequest(t *testing.T, h *pagesHarness) SetupRequest { + return SetupRequest{ + RepoRoot: h.repo.Root(), Provider: "github-pages", Asker: h.ask, Forge: h.forge, + Adapter: githubpages.Adapter{API: h.pages.API}, Credentials: noCredential{t}, Context: context.Background(), + } +} + +// pagesWrites are the calls that changed the Pages fake: everything but a read. +func pagesWrites(h *pagesHarness) []string { + var out []string + for _, c := range h.pages.CallLog() { + if !strings.HasPrefix(c, "GET ") { + out = append(out, c) + } + } + return out +} + +// refusedBeforeAnything asserts a refusal changed nothing anywhere. +func refusedBeforeAnything(t *testing.T, h *pagesHarness, err error, want string) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), "\n") { + t.Fatalf("setup = %v, want a one-line refusal naming %q", err, want) + } + if got := changedPaths(t, h.harness); len(got) != 0 { + t.Fatalf("the refused run changed %v", got) + } + if len(h.forge.writes) != 0 || len(pagesWrites(h)) != 0 { + t.Fatalf("the refused run reached the forge: %v %v", h.forge.writes, pagesWrites(h)) + } + if len(h.ask.questions) != 0 { + t.Fatalf("the refused run asked %q", h.ask.questions) + } +} + +// A repository that already serves Pages at a custom domain, set up without +// --domain, would get a workflow built for // while GitHub serves the +// site at the domain's root, so every asset would 404. Setup refuses before +// it asks or writes anything, and says where to declare the domain. +func TestALiveDomainWithNoneAskedForIsRefusedBeforeAnything(t *testing.T) { + h := newPagesHarness(t) + h.pages.Repos["example-owner/example-site"].Pages = &githubpagestest.Pages{BuildType: "workflow", CNAME: "docs.example.com"} + _, err := Setup(pagesRequest(t, h)) + refusedBeforeAnything(t, h, err, "pass --domain docs.example.com when setup writes the hosting block, or set the hosting block's domain") +} + +// A Pages site GitHub serves below a path other than the one the workflow +// builds for is refused before anything is written. +func TestASiteServedBelowAnotherPathIsRefusedBeforeAnything(t *testing.T) { + h := newPagesHarness(t) + h.pages.Repos["example-owner/example-site"].Pages = &githubpagestest.Pages{BuildType: "workflow", + HTMLURL: "https://example-owner.github.io/elsewhere/"} + _, err := Setup(pagesRequest(t, h)) + refusedBeforeAnything(t, h, err, "/elsewhere/") +} + +// The host stage holds the same line on its own: an address served below a +// path other than the profile's base path is refused, never reported live. +func TestTheHostStageRefusesASiteServedBelowAnotherPath(t *testing.T) { + f := githubpagestest.New() + f.Repos["acme/widgets"].Pages = &githubpagestest.Pages{BuildType: "workflow", HTMLURL: "https://acme.github.io/elsewhere/"} + out, declined, refused := setupHost(context.Background(), githubpages.Adapter{API: f.API}, + hosting.Site{Name: "widgets", Repo: "acme/widgets"}, true, SetupRequest{Asker: answer(true)}) + if !refused || declined || out.Status != HostRefused { + t.Fatalf("host = %+v (declined %v, refused %v), want refused", out, declined, refused) + } + if out.Address != "" || !strings.Contains(out.Detail, "/elsewhere/") || !strings.Contains(out.Detail, "/widgets/") { + t.Fatalf("host = %+v, want no address and a detail naming both paths", out) + } +} + +// A repository that already publishes a Pages site built by a workflow is +// not taken over silently: the switch question names the site's address and +// build type, a no leaves everything as it was, and the host outcome names it. +func TestAnExistingPagesSiteIsNamedBeforeItIsPublishedOver(t *testing.T) { + existing := "https://example-owner.github.io/example-site/ (build type workflow)" + t.Run("declined", func(t *testing.T) { + h := newPagesHarness(t) + h.pages.Repos["example-owner/example-site"].Pages = &githubpagestest.Pages{BuildType: "workflow"} + h.ask.yes = false + res := h.run(t) + if len(h.ask.questions) != 1 || !strings.Contains(h.ask.questions[0], existing) { + t.Fatalf("questions = %q, want the switch naming %q", h.ask.questions, existing) + } + if res.Status != StatusDeclined || len(changedPaths(t, h.harness)) != 0 || len(pagesWrites(h)) != 0 || len(h.forge.writes) != 0 { + t.Fatalf("a declined takeover = %q, changed %v, wrote %v %v", res.Status, changedPaths(t, h.harness), pagesWrites(h), h.forge.writes) + } + }) + t.Run("confirmed", func(t *testing.T) { + h := newPagesHarness(t) + h.pages.Repos["example-owner/example-site"].Pages = &githubpagestest.Pages{BuildType: "workflow"} + res := h.run(t) + if len(h.ask.questions) == 0 || !strings.Contains(h.ask.questions[0], existing) { + t.Fatalf("questions = %q, want the switch naming %q", h.ask.questions, existing) + } + if res.Host.Status != HostCurrent || !strings.Contains(res.Host.Detail, existing) { + t.Fatalf("host = %+v, want the outcome naming %q", res.Host, existing) + } + }) + // A composition that already holds the switch but has no workflow is + // asked the switch again before the workflow is added, and that question + // names the site it would publish over. + t.Run("composition already switched on", func(t *testing.T) { + h := newPagesHarness(t) + h.run(t) + h.repo.Git("add", "-A") + h.repo.Git("commit", "-q", "-m", "switch the project site on") + if err := os.Remove(filepath.Join(h.repo.Root(), SiteWorkflowRelPath)); err != nil { + t.Fatal(err) + } + h.ask.questions, h.ask.yes = nil, false + writes := len(pagesWrites(h)) + res := h.run(t) + if len(h.ask.questions) != 1 || !strings.Contains(h.ask.questions[0], existing) { + t.Fatalf("questions = %q, want the switch naming %q", h.ask.questions, existing) + } + if res.Status != StatusDeclined || len(pagesWrites(h)) != writes { + t.Fatalf("a declined takeover = %q, wrote %v", res.Status, pagesWrites(h)[writes:]) + } + if got := changedPaths(t, h.harness); !reflect.DeepEqual(got, []string{SiteWorkflowRelPath}) { + t.Fatalf("a declined takeover changed %v, want only the workflow the test removed", got) + } + }) +} + +// The project workflow downloads abcd's latest release to build with, and a +// release made before project sites cannot build one. Setup cannot tell +// without asking the network which release is latest, so a project site's +// setup says so every time; a composed site's does not. +func TestAProjectSiteSetupSaysItNeedsAReleaseThatBuildsProjectSites(t *testing.T) { + const want = "until abcd publishes a release that builds project sites" + res := newPagesHarness(t).run(t) + if !strings.Contains(strings.Join(res.Notes, "\n"), want) { + t.Errorf("a project site's setup does not name the release it needs: %q", res.Notes) + } + composed := newHarness(t).run(t) + if strings.Contains(strings.Join(composed.Notes, "\n"), want) { + t.Errorf("a composed site's setup names the project route's release: %q", composed.Notes) + } +} diff --git a/internal/core/site/setup_test.go b/internal/core/site/setup_test.go index a6cda5579..77b4f81bd 100644 --- a/internal/core/site/setup_test.go +++ b/internal/core/site/setup_test.go @@ -42,6 +42,8 @@ type fakeForge struct { protection map[string][]string // defaultBranch is the repository's default branch as the forge names it. defaultBranch string + // private is whether the forge reports the repository private. + private bool } func newFakeForge() *fakeForge { @@ -61,6 +63,13 @@ func (f *fakeForge) DefaultBranch(context.Context) (string, error) { return f.defaultBranch, nil } +func (f *fakeForge) Private(context.Context) (bool, error) { + if err := f.fail["Private"]; err != nil { + return false, err + } + return f.private, nil +} + func (f *fakeForge) Environments(context.Context) (map[string]EnvironmentState, error) { if err := f.fail["Environments"]; err != nil { return nil, err @@ -347,15 +356,17 @@ func mustRead(t *testing.T, root, rel string) []byte { // --- 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) +func TestTheProviderListHasTwoAdaptersBehindTheSeam(t *testing.T) { + if got := Providers(); !reflect.DeepEqual(got, []string{"cloudflare", "github-pages"}) { + t.Fatalf("providers = %v, want exactly [cloudflare github-pages]", got) } - a, ok := adapterNamed("cloudflare") - if !ok { - t.Fatal("the listed provider does not resolve") + for _, name := range Providers() { + a, ok := adapterNamed(name) + if !ok { + t.Fatalf("the listed provider %s does not resolve", name) + } + var _ hosting.Adapter = a } - var _ hosting.Adapter = a } // --- criterion 4: the page set for any managed repository ------------------------ @@ -672,7 +683,7 @@ func TestAnUnsafeNameOrDomainIsRefused(t *testing.T) { // 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{}) + wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}, hosting.Site{Name: "x"}, false) if err != nil { t.Fatal(err) } @@ -709,7 +720,7 @@ func TestTheWorkflowPinsFollowAbcdsOwn(t *testing.T) { if err != nil { t.Fatal(err) } - wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}) + wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}, hosting.Site{Name: "x"}, false) if err != nil { t.Fatal(err) } @@ -755,7 +766,7 @@ func TestTheSeedSourcesAreAbcdsOwn(t *testing.T) { // 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{}) + wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}, hosting.Site{Name: "x"}, false) if err != nil { t.Fatal(err) } @@ -778,7 +789,7 @@ func TestAWorkflowRunFromAForkNeverRenders(t *testing.T) { // 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{}) + wf, err := renderSiteWorkflow("trunk", cloudflare.Adapter{}, hosting.Site{Name: "x"}, false) if err != nil { t.Fatal(err) } diff --git a/internal/core/site/setup_workflow_abcd_test.go b/internal/core/site/setup_workflow_abcd_test.go new file mode 100644 index 000000000..e18202e2f --- /dev/null +++ b/internal/core/site/setup_workflow_abcd_test.go @@ -0,0 +1,195 @@ +package site + +// How the workflow setup writes obtains abcd's own binary before running it: +// the release is chosen from abcd's stable release list by version, never by +// the "latest" mark, and the binary's build provenance is held to release.yml +// as it stands on abcd's main branch, built from that release tag's commit on +// a GitHub-hosted runner. The download step is run with bash against a fake gh; +// nothing here reaches a network. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare" + "github.com/intentdriven/abcd/internal/adapter/hosting/githubpages" +) + +// bothProviderWorkflows renders the workflow for each provider, since the two +// share the steps that fetch and verify abcd. +func bothProviderWorkflows(t *testing.T) map[string]string { + t.Helper() + out := map[string]string{} + for name, render := range map[string]func() ([]byte, error){ + "cloudflare": func() ([]byte, error) { + return renderSiteWorkflow("main", cloudflare.Adapter{}, hosting.Site{Name: "x"}, false) + }, + "github-pages": func() ([]byte, error) { + return renderSiteWorkflow("main", githubpages.Adapter{}, hosting.Site{Name: "widgets", Repo: "acme/widgets"}, true) + }, + } { + wf, err := render() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + out[name] = string(wf) + } + return out +} + +// stepRunBlock is the run block of the step whose name is name, unindented. +func stepRunBlock(t *testing.T, wf, name string) string { + t.Helper() + lines := strings.Split(wf, "\n") + start := -1 + for i, l := range lines { + if strings.TrimSpace(l) == "- name: "+name { + start = i + break + } + } + if start < 0 { + t.Fatalf("the workflow has no step named %q", name) + } + var script []string + runIndent := -1 + for _, l := range lines[start+1:] { + indent := len(l) - len(strings.TrimLeft(l, " ")) + if runIndent < 0 { + if strings.HasPrefix(strings.TrimSpace(l), "- name:") { + break + } + if strings.TrimSpace(l) == "run: |" { + runIndent = indent + } + continue + } + if strings.TrimSpace(l) != "" && indent <= runIndent { + break + } + script = append(script, strings.TrimPrefix(l, strings.Repeat(" ", runIndent+2))) + } + if len(script) == 0 { + t.Fatalf("the step %q has no run block", name) + } + return strings.Join(script, "\n") + "\n" +} + +const ( + downloadStepName = "Download abcd and its checksums" + verifyStepName = "Verify abcd against its release manifest and its provenance" +) + +// TestTheWorkflowHoldsAbcdToReleaseYMLOnMain: the check names the whole +// certificate identity at refs/heads/main, the source ref, the release tag's +// commit and a GitHub-hosted runner, rather than the workflow path alone. +func TestTheWorkflowHoldsAbcdToReleaseYMLOnMain(t *testing.T) { + for provider, wf := range bothProviderWorkflows(t) { + t.Run(provider, func(t *testing.T) { + if strings.Contains(wf, "--signer-workflow") { + t.Error("the workflow verifies abcd with --signer-workflow, which never checks the ref the workflow ran from") + } + verify := stepRunBlock(t, wf, verifyStepName) + for _, want := range []string{ + `commit="$(gh api "repos/` + abcdReleaseRepo + `/commits/${ABCD_TAG}" --jq .sha)"`, + `[[ "$commit" =~ ^[0-9a-f]{40}$ ]]`, + "gh attestation verify abcd-linux-amd64 --repo " + abcdReleaseRepo + " \\\n", + "--cert-identity https://github.com/" + abcdReleaseRepo + "/.github/workflows/release.yml@refs/heads/main \\\n", + "--source-ref refs/heads/main", + `--source-digest "$commit"`, + "--deny-self-hosted-runners", + } { + if !strings.Contains(verify, want) { + t.Errorf("the verify step lacks %q:\n%s", want, verify) + } + } + if !strings.Contains(verify, "grep ' abcd-linux-amd64$' checksums.txt | sha256sum -c -") { + t.Errorf("the verify step dropped the checksum line:\n%s", verify) + } + if !strings.Contains(wf, " ABCD_TAG: ${{ steps.abcd.outputs.tag }}\n") { + t.Error("the verify step is not handed the downloaded release's tag through env") + } + }) + } +} + +// TestTheWorkflowFetchesAbcdsHighestStableRelease: the download takes the +// highest vX.Y.Z of abcd's published, non-pre-release releases, by version, and +// downloads exactly that tag. The step is run against a fake gh. +func TestTheWorkflowFetchesAbcdsHighestStableRelease(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not installed") + } + for provider, wf := range bothProviderWorkflows(t) { + script := stepRunBlock(t, wf, downloadStepName) + for name, c := range map[string]struct { + list string + want string // the tag downloaded; empty means the step must refuse + wantErr bool + }{ + // Listed newest first, as gh lists them: a re-marked or newer + // lower release, a pre-release-shaped tag and a malformed one are + // all passed over, and v0.14.1 beats v0.9.0 by number. + "the highest version, not the newest": {list: "v0.9.0\nv0.14.1\nv0.14.0\nv1.0.0-rc.1\nv2.0\nv3.0.0;id\n", want: "v0.14.1"}, + "a single release": {list: "v1.2.3\n", want: "v1.2.3"}, + "no stable release": {list: "v1.0.0-rc.1\nnightly\n", wantErr: true}, + "no release at all": {list: "", wantErr: true}, + } { + t.Run(provider+"/"+name, func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.Mkdir(bin, 0o755); err != nil { + t.Fatal(err) + } + calls := filepath.Join(dir, "calls") + fake := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"$GH_CALLS\"\n" + + "if [ \"$1 $2\" = 'release list' ]; then printf '%s' \"$GH_LIST\"; fi\n" + if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(fake), 0o755); err != nil { + t.Fatal(err) + } + output := filepath.Join(dir, "github_output") + if err := os.WriteFile(output, nil, 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", "-c", script) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "GH_CALLS="+calls, "GH_LIST="+c.list, + "GITHUB_OUTPUT="+output, "RUNNER_TEMP="+dir) + out, err := cmd.CombinedOutput() + raw, _ := os.ReadFile(calls) + got := string(raw) + if strings.Contains(got, "release view") { + t.Errorf("the step asked gh for the latest-marked release:\n%s", got) + } + if !strings.Contains(got, "release list --repo "+abcdReleaseRepo+" --exclude-drafts --exclude-pre-releases") { + t.Errorf("the step did not list abcd's stable releases:\n%s", got) + } + if c.wantErr { + if err == nil { + t.Fatalf("the step succeeded with no stable release to fetch:\n%s", out) + } + if strings.Contains(got, "release download") { + t.Errorf("the step downloaded with no stable release chosen:\n%s", got) + } + return + } + if err != nil { + t.Fatalf("the step failed: %v\n%s", err, out) + } + if want := "release download " + c.want + " --repo " + abcdReleaseRepo + " "; !strings.Contains(got, want) { + t.Errorf("the step did not download exactly %s:\n%s", c.want, got) + } + if o, _ := os.ReadFile(output); strings.TrimSpace(string(o)) != "tag="+c.want { + t.Errorf("the step wrote %q to GITHUB_OUTPUT, want tag=%s", o, c.want) + } + }) + } + } +} diff --git a/internal/core/site/setup_workflow_golden_test.go b/internal/core/site/setup_workflow_golden_test.go new file mode 100644 index 000000000..0045eee7c --- /dev/null +++ b/internal/core/site/setup_workflow_golden_test.go @@ -0,0 +1,20 @@ +package site + +import ( + "testing" + + "github.com/intentdriven/abcd/internal/adapter/hosting" + "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare" +) + +// TestTheCloudflareWorkflowIsUnchanged pins the workflow setup writes for the +// cloudflare provider byte for byte, so a managed repository that set its +// site up before the github-pages provider existed finds its workflow current +// and is not asked to replace it. +func TestTheCloudflareWorkflowIsUnchanged(t *testing.T) { + wf, err := renderSiteWorkflow("main", cloudflare.Adapter{}, hosting.Site{Name: "x"}, false) + if err != nil { + t.Fatal(err) + } + golden(t, "site-workflow-cloudflare.yml", wf) +} diff --git a/internal/core/site/setupsrc/record.js b/internal/core/site/setupsrc/record.js index 8f2f10145..9646a381e 100644 --- a/internal/core/site/setupsrc/record.js +++ b/internal/core/site/setupsrc/record.js @@ -118,9 +118,12 @@ return { i: i, id: n.id, type: n.type, status: n.lifecycle || n.status || '', title: n.title, path: n.path, kind: n.kind, sev: n.severity, date: n.date, - created: (n.dates && n.dates.created) || n.date, - entered: (n.dates && n.dates.entered) || n.date, - last: (n.dates && n.dates.touched) || n.date, + // A record exported without git dates (a project site) keeps only its + // own date field, shown as that field, never as a git date. + created: n.dates ? (n.dates.created || n.date) : '', + entered: n.dates ? (n.dates.entered || n.date) : '', + last: n.dates ? (n.dates.touched || n.date) : '', + nogit: !n.dates, hx: { date: coil.x * CR, links: links.x * CR }, hy: { date: coil.y * CR, links: links.y * CR }, r: L.radius[i], x: 0, y: 0, vx: 0, vy: 0, a: 1, ta: 1, s: 1 @@ -723,11 +726,12 @@ if (W_.blob) { foot += '' + esc(W_.open) + ' ↗'; } - if (W_.commits && n.last) { + if (W_.commits && (n.last || n.nogit)) { /* The last-touched date LINKS the record's commit history, so an - amendment is traceable from the card rather than merely visible. */ + amendment is traceable from the card rather than merely visible. + A record exported without git dates links its history undated. */ foot += '' + esc(W_.history) + - ' · ' + esc(fmtShort(n.last)) + ' ↗'; + (n.last ? ' · ' + esc(fmtShort(n.last)) : '') + ' ↗'; } card.innerHTML = diff --git a/internal/core/site/setupsrc/site.css b/internal/core/site/setupsrc/site.css index 13614d13c..757317fb4 100644 --- a/internal/core/site/setupsrc/site.css +++ b/internal/core/site/setupsrc/site.css @@ -110,6 +110,11 @@ th{font-family:"Bricolage Grotesque",sans-serif;font-weight:650;font-size:.82rem that duplication would mean changing the markup, not this rule. */ .sr,th .th-portrait~span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +/* ---------- the line every page opens with (itd-2610091010348892) ---------- */ +.managed{margin:0;padding:6px 16px;text-align:center;font-size:.8rem;line-height:1.4;color:var(--ink-2);background:var(--ground);border-bottom:1px solid var(--rule)} +.managed a{color:inherit} +.managed small{font-size:inherit} + /* ---------- site header ---------- */ .site-head{background:var(--ground);border-bottom:1px solid var(--rule)} .site-head .wrap{display:flex;align-items:center;gap:22px;min-height:64px;flex-wrap:wrap} diff --git a/internal/core/site/setupsrc/site.yml.tmpl b/internal/core/site/setupsrc/site.yml.tmpl index 56749b7d3..59c055866 100644 --- a/internal/core/site/setupsrc/site.yml.tmpl +++ b/internal/core/site/setupsrc/site.yml.tmpl @@ -7,9 +7,22 @@ name: site # Renders this repository's site from each published release with abcd's own # released binary — checksum- and attestation-verified before it runs — and # deploys the rendered artefact to <% .Provider %>. The render job holds no -# credential; the deploy job holds the provider's and runs nothing from the +<% if .HostConfig %># credential; the deploy job holds the provider's and runs nothing from the # repository but the host configuration file (<% .HostConfig %>). -# +<% else %># credential, and neither does the deploy job: it deploys with the run's own +# short-lived token and runs nothing from the repository. +<% end %><% if .Project %># +# A PROJECT SITE (`--project`): every page is built from what abcd already +# writes to the repository (its identity block, brief, development record and +# CHANGELOG.md), and its interface words, styles and scripts come from the +# abcd binary. The repository carries no site sources. +<% end %><% if .Switched %># +# THE SWITCH: the site publishes only while `"publish": true` stands in the +# hosting block of <% .SwitchFile %> both at the released commit and at the +# head of <% .DefaultBranch %>, and only while the repository is public. +# Switched off, a release builds and publishes nothing, a later push or +# dispatch included, and `abcd site setup` offers to remove this file. +<% end %># # Three entry points, one path: # # release: published a release published by a person or a personal token. @@ -34,7 +47,7 @@ name: site # # site-render the render job. No secrets. Deployment policy: branch # `<% .DefaultBranch %>` and tags `v*`. -# site the deploy job. Same policy. Secrets:<% range .Secrets %> <% . %><% end %>. +# <% printf "%-13s" .DeployEnv %> the deploy job. Same policy. <% if .Secrets %>Secrets:<% range .Secrets %> <% . %><% end %>.<% else %>No secrets: the run's own token.<% end %> # # The policy refuses unreviewed workflow content: a dispatch runs this file as # it exists on the ref it was dispatched from, so a branch that is neither the @@ -86,7 +99,8 @@ jobs: contents: read outputs: tag: ${{ steps.release.outputs.tag }} - steps: +<% if .Switched %> publish: ${{ steps.switch.outputs.on }} +<% end %> steps: - name: Resolve the release to render id: release env: @@ -118,35 +132,74 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Download abcd and its checksums +<% if .Switched %> # On only while all three hold: the repository is public (Pages from a + # private repository is public on every plan but Enterprise), and the + # switch is on both at the released commit and at the head of + # <% .DefaultBranch %>, so a switch turned off there is not undone by a + # later push or dispatch that renders an older tag. A missing or + # malformed file reads as off. + - name: Read the project-site switch + id: switch env: + REPO_PRIVATE: ${{ github.event.repository.private }} + run: | + set -euo pipefail + on=true + if [ "${REPO_PRIVATE:-}" != false ]; then + echo "::notice::this repository is not public, so this release publishes nothing" + on=false + fi + if ! jq -e '.hosting.publish == true' <% .SwitchFile %> >/dev/null 2>&1; then + echo "::notice::the project site is switched off in <% .SwitchFile %> at the released commit, so this release publishes nothing" + on=false + fi + if ! git show "origin/<% .DefaultBranch %>:<% .SwitchFile %>" 2>/dev/null | jq -e '.hosting.publish == true' >/dev/null 2>&1; then + echo "::notice::the project site is switched off in <% .SwitchFile %> on <% .DefaultBranch %>, so this release publishes nothing" + on=false + fi + echo "on=$on" >> "$GITHUB_OUTPUT" + +<% end %> # abcd's highest stable vX.Y.Z release by version, never the release + # marked "latest": that mark can be moved to any release. + - name: Download abcd and its checksums + id: abcd +<% .IfOn %> env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - abcd_tag="$(gh release view --repo <% .AbcdRepo %> --json tagName --jq .tagName)" - tag_re='^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' - if [[ ! $abcd_tag =~ $tag_re ]]; then - echo "::error::abcd's latest release tag is not vX.Y.Z; refusing to download it" + tags="$(gh release list --repo <% .AbcdRepo %> --exclude-drafts --exclude-pre-releases \ + --limit 1000 --json tagName --jq '.[].tagName')" + version="$(printf '%s\n' "$tags" | sed -n 's/^v\([0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\)$/\1/p' \ + | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1)" + ABCD_TAG="v${version}" + if ! [[ "$ABCD_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::<% .AbcdRepo %> has no published stable vX.Y.Z release to render with" exit 1 fi - gh release download "$abcd_tag" --repo <% .AbcdRepo %> \ + echo "tag=$ABCD_TAG" >> "$GITHUB_OUTPUT" + gh release download "$ABCD_TAG" --repo <% .AbcdRepo %> \ --pattern 'abcd-linux-amd64' --pattern 'checksums.txt' --dir "$RUNNER_TEMP" - printf '%s\n' "$abcd_tag" > "$RUNNER_TEMP/abcd-tag" - # The provenance is held to abcd's release workflow on main, built from - # main on a GitHub-hosted runner, at the commit the downloaded release's - # tag names. + # The checksum is integrity only: checksums.txt sits on the same release + # page as the binary, so whoever can replace one can replace both. The + # provenance is the authenticity check: it is held to abcd's release + # workflow on main, built from main on a GitHub-hosted runner, at the + # commit the downloaded release's tag names. - name: Verify abcd against its release manifest and its provenance - env: +<% .IfOn %> env: GH_TOKEN: ${{ github.token }} + ABCD_TAG: ${{ steps.abcd.outputs.tag }} run: | set -euo pipefail + if ! [[ "$ABCD_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::the abcd release tag is not vX.Y.Z; refusing to verify it" + exit 1 + fi cd "$RUNNER_TEMP" grep ' abcd-linux-amd64$' checksums.txt | sha256sum -c - - abcd_tag="$(cat abcd-tag)" - commit="$(gh api "repos/<% .AbcdRepo %>/commits/${abcd_tag}" --jq .sha)" - if [[ ! $commit =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::the commit of abcd $abcd_tag could not be resolved; refusing to run it" + commit="$(gh api "repos/<% .AbcdRepo %>/commits/${ABCD_TAG}" --jq .sha)" + if ! [[ "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::the commit of abcd $ABCD_TAG could not be resolved; refusing to run it" exit 1 fi gh attestation verify abcd-linux-amd64 --repo <% .AbcdRepo %> \ @@ -157,18 +210,18 @@ jobs: install -m 0755 abcd-linux-amd64 "${RUNNER_TEMP}/abcd" - name: Render and check the site - env: +<% .IfOn %> env: TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail - "${RUNNER_TEMP}/abcd" site build --out site --version "${TAG#v}" --commit "$(git rev-parse --verify HEAD)" - "${RUNNER_TEMP}/abcd" lint site --out site + "${RUNNER_TEMP}/abcd" site build<% .ProjectFlag %> --out site --version "${TAG#v}" --commit "$(git rev-parse --verify HEAD)"<% .BaseFlag %> + "${RUNNER_TEMP}/abcd" lint site<% .ProjectFlag %> --out site<% .BaseFlag %> - name: Pack the rendered site - run: tar -czf site.tar.gz -C site . +<% .IfOn %> run: tar -czf site.tar.gz -C site . - name: Hand the rendered site to the deploy job - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 +<% .IfOn %> uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: site-${{ steps.release.outputs.tag }} path: site.tar.gz @@ -177,19 +230,22 @@ jobs: deploy: needs: render - runs-on: ubuntu-latest +<% if .Switched %> if: needs.render.outputs.publish == 'true' && toJSON(github.event.repository.private) == 'false' + # The visibility is compared as JSON: a loose `== false` reads a missing + # field as false, which would read as public. +<% end %> runs-on: ubuntu-latest timeout-minutes: 15 - environment: site + environment: <% .DeployEnv %> permissions: - contents: read - steps: - - name: Check out the released commit for the host configuration +<% range .DeployPermissions %> <% . %> +<% end %> steps: +<% if .HostConfig %> - name: Check out the released commit for the host configuration uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: refs/tags/${{ needs.render.outputs.tag }} persist-credentials: false - - name: Collect the rendered site +<% end %> - name: Collect the rendered site uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: site-${{ needs.render.outputs.tag }} diff --git a/internal/core/site/setupsrc/ui.json b/internal/core/site/setupsrc/ui.json index c0d4be2f4..37bce38d4 100644 --- a/internal/core/site/setupsrc/ui.json +++ b/internal/core/site/setupsrc/ui.json @@ -104,6 +104,7 @@ "read_script": "read the script", "unreleased": "unreleased", "beta": "Beta", + "managed_record": "This is the record of an abcd-managed repository", "health": { "unresolved": "References a record the tree does not hold", "isolated": "Linked to nothing, and nothing links to it", diff --git a/internal/core/site/status.go b/internal/core/site/status.go index 3886d56ea..84736bd50 100644 --- a/internal/core/site/status.go +++ b/internal/core/site/status.go @@ -51,7 +51,7 @@ func (e *explorer) statusRows(rows []statusblock.Row) string { for _, r := range rows { id := escapeText(r.ID) if route := e.routeOf(r.ID); route != "" { - id = `` + id + `` + id = `` + id + `` } b.WriteString(`
  • ` + id + `` + escapeText(r.Title) + ``) if tag := e.statusTag(r); tag != "" { diff --git a/internal/core/site/testdata/contributors.html b/internal/core/site/testdata/contributors.html index 0210887f6..8e99ec833 100644 --- a/internal/core/site/testdata/contributors.html +++ b/internal/core/site/testdata/contributors.html @@ -11,7 +11,7 @@ -
  • Authors of recordcommits
    Fixture6

    Human author of record. The human contributor is the author of record and +

    This is the record of an abcd-managed repository

    Contributors

    Authors of record1

    Authors of recordcommits
    Fixture6

    Human author of record. The human contributor is the author of record and is responsible for all assisted output. A trailer is disclosure, never an authorship claim.

    CONTRIBUTING.md

    Assisted-by trailers1

    Assistant:assistant-model-11

    declared no assistance 3

    composed by abcd from record facts 0

    no declaration 1

    diff --git a/internal/core/site/testdata/index.html b/internal/core/site/testdata/index.html index 4bb3c7102..a2ec9f1da 100644 --- a/internal/core/site/testdata/index.html +++ b/internal/core/site/testdata/index.html @@ -12,7 +12,7 @@ -

    fixture — A Fixture Product

    Who this is for

    A fixture lede with a link, some emphasis, some +

    This is the record of an abcd-managed repository

    fixture — A Fixture Product

    Who this is for

    A fixture lede with a link, some emphasis, some strength, and a code span.

    A fixture tagline for a fixture product.

    A fixture pitch, two clauses long, so the hero has something to carry in its aside.

    A fixture cartoon

    Roles

    The fixture has two roles, and this paragraph introduces them.

    The product thinker

    Product thinker

    The one who holds the why.

    The facilitator

    Technical facilitator

    The one who translates it.

    Artefacts

    A fixture opening paragraph about the artefacts.

    diff --git a/internal/core/site/testdata/record-adr-1.html b/internal/core/site/testdata/record-adr-1.html index d58441c84..07902e1c4 100644 --- a/internal/core/site/testdata/record-adr-1.html +++ b/internal/core/site/testdata/record-adr-1.html @@ -11,5 +11,5 @@ -

    The first decision

    adracceptedadr-1

    The first decision's body, which mentions iss-1 in prose.

    Frontmatter

    idadr-1
    typeadr
    statusaccepted
    date2026-01-02
    created2026-01-05
    entered2026-01-05
    touched2026-01-05

    References2

    mentions3

    +

    This is the record of an abcd-managed repository

    The first decision

    adracceptedadr-1

    The first decision's body, which mentions iss-1 in prose.

    Frontmatter

    idadr-1
    typeadr
    statusaccepted
    date2026-01-02
    created2026-01-05
    entered2026-01-05
    touched2026-01-05

    References2

    mentions3

    diff --git a/internal/core/site/testdata/record-dashboard.html b/internal/core/site/testdata/record-dashboard.html index fede9e7e5..13bbad6ef 100644 --- a/internal/core/site/testdata/record-dashboard.html +++ b/internal/core/site/testdata/record-dashboard.html @@ -11,5 +11,5 @@ -
    +

    This is the record of an abcd-managed repository

    diff --git a/internal/core/site/testdata/record-foundations.html b/internal/core/site/testdata/record-foundations.html index 2b45632a1..4fdb54fda 100644 --- a/internal/core/site/testdata/record-foundations.html +++ b/internal/core/site/testdata/record-foundations.html @@ -11,5 +11,5 @@ -
    +

    This is the record of an abcd-managed repository

    diff --git a/internal/core/site/testdata/record-graph.html b/internal/core/site/testdata/record-graph.html index 3413f4cde..5e0af3180 100644 --- a/internal/core/site/testdata/record-graph.html +++ b/internal/core/site/testdata/record-graph.html @@ -12,5 +12,5 @@ -

    Relationships

    Stand by…
    Browse as a list
    +

    This is the record of an abcd-managed repository

    Relationships

    Stand by…
    Browse as a list
    diff --git a/internal/core/site/testdata/record-itd-2.html b/internal/core/site/testdata/record-itd-2.html index 8a715222b..af3a77ebb 100644 --- a/internal/core/site/testdata/record-itd-2.html +++ b/internal/core/site/testdata/record-itd-2.html @@ -11,7 +11,7 @@ -

    The Shipped One

    intentshippedminorstandaloneitd-2

    Press Release

    +

    This is the record of an abcd-managed repository

    The Shipped One

    intentshippedminorstandaloneitd-2

    Press Release

    A fixture user opens the page and sees the record. They had been reading frontmatter on a forge; now one page says what the project decided and why. "I stopped guessing," said the fixture user.

    diff --git a/internal/core/site/testdata/record-principle.html b/internal/core/site/testdata/record-principle.html index 755062c61..1979316e9 100644 --- a/internal/core/site/testdata/record-principle.html +++ b/internal/core/site/testdata/record-principle.html @@ -11,7 +11,7 @@ -

    One fixture principle

    principleone-fixture-principle

    The rule. A principle carries no frontmatter, so the lint scan cannot see +

    This is the record of an abcd-managed repository

    One fixture principle

    principleone-fixture-principle

    The rule. A principle carries no frontmatter, so the lint scan cannot see it and the site reads the store directly. It sits beside the decisions and is not a missing thing.

    Why. It exercises the frontmatter-free path, mentioning adr-1 in passing.

    mentions1

    diff --git a/internal/core/site/testdata/references.html b/internal/core/site/testdata/references.html index d03b285c0..a03895dc1 100644 --- a/internal/core/site/testdata/references.html +++ b/internal/core/site/testdata/references.html @@ -11,7 +11,7 @@ -

    References

    References & sources2

    1. Fenella Quill. 2019. On the making of fixtures. Journal of Invented Sources 4, 2. 11-19. doi:10.5555/fixture.2019.4
    2. Orrin Tamsin and Iris Vole. 2021. Rendering the record. Invented Press, Nowhere. https://example.invalid/rendering-the-record

    .abcd/development/research/references.csl.json

    Inspirations2

    Ideas that shaped the fixture — not code it depends on.

      +

      This is the record of an abcd-managed repository

      References

      References & sources2

      1. Fenella Quill. 2019. On the making of fixtures. Journal of Invented Sources 4, 2. 11-19. doi:10.5555/fixture.2019.4
      2. Orrin Tamsin and Iris Vole. 2021. Rendering the record. Invented Press, Nowhere. https://example.invalid/rendering-the-record

      .abcd/development/research/references.csl.json

      Inspirations2

      Ideas that shaped the fixture — not code it depends on.

      • A first invented inspiration — the shape of the fixture's record.
      • A second invented inspiration — the shape of its pages.
      diff --git a/internal/core/site/testdata/site-workflow-cloudflare.yml b/internal/core/site/testdata/site-workflow-cloudflare.yml new file mode 100644 index 000000000..95b6c84d6 --- /dev/null +++ b/internal/core/site/testdata/site-workflow-cloudflare.yml @@ -0,0 +1,241 @@ +name: site + +# Written by `abcd site setup` (itd-2609061543533170). Re-running the verb +# rewrites nothing while this file is unchanged, and refuses a hand edit unless +# it is told to replace it. +# +# Renders this repository's site from each published release with abcd's own +# released binary — checksum- and attestation-verified before it runs — and +# deploys the rendered artefact to cloudflare. The render job holds no +# credential; the deploy job holds the provider's and runs nothing from the +# repository but the host configuration file (wrangler.jsonc). +# +# Three entry points, one path: +# +# release: published a release published by a person or a personal token. +# workflow_run the `release` or `auto-release` workflow finishing on +# main or on a v-tag. A release created with +# the workflow's own GITHUB_TOKEN fires no `release` +# event, so a release workflow's completion is what +# carries those releases here: `auto-release` runs on +# every push to main and calls `release` inside +# its own run, and a hand-pushed tag runs `release` with +# the tag as its head branch. A completion that +# released nothing redeploys the latest published +# release: the same bytes the site already serves, +# unless a dispatch rolled it back to an older tag, +# which the next such completion replaces with the +# latest release again. +# workflow_dispatch a redeploy by hand; an empty tag means the latest +# published release. +# +# THE ENVIRONMENT CONTRACT (the tree's record of the forge's state; `abcd site +# setup` creates both through the forge's API when it is asked to): +# +# site-render the render job. No secrets. Deployment policy: branch +# `main` and tags `v*`. +# site the deploy job. Same policy. Secrets: CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID. +# +# The policy refuses unreviewed workflow content: a dispatch runs this file as +# it exists on the ref it was dispatched from, so a branch that is neither the +# default branch nor a release tag is refused before either job starts. +on: + release: + types: [published] + workflow_run: + workflows: [release, auto-release] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Release tag to render and deploy (vX.Y.Z). Empty means the latest published release. + required: false + type: string + default: '' + +# One production target, so one queue. A deploy cancelled between uploading +# assets and switching the route leaves the site half-moved. +concurrency: + group: site-production + cancel-in-progress: false + +permissions: + contents: read + +jobs: + render: + # A workflow_run fires for the release workflows' runs whatever triggered + # them, and a head branch is a name a fork can choose. Only a successful + # run of this repository's own release workflows, not of a pull request, + # on the default branch or a v-tag (the trigger filters no branch, because + # a tag-push run's head branch is the tag), reaches the render. Nothing + # from the triggering run is used either way: the tag is resolved from the + # repository's published releases. + if: >- + ${{ github.event_name != 'workflow_run' || ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event != 'pull_request' && + github.event.workflow_run.event != 'pull_request_target' && + github.event.workflow_run.head_repository.full_name == github.repository && + (github.event.workflow_run.head_branch == 'main' || + startsWith(github.event.workflow_run.head_branch, 'v'))) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: site-render + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + steps: + - name: Resolve the release to render + id: release + env: + GH_TOKEN: ${{ github.token }} + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag="${EVENT_TAG:-${INPUT_TAG:-}}" + if [ -z "$tag" ]; then + tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" + fi + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then + echo "::error::the release tag is not vX.Y.Z; refusing to render it" + exit 1 + fi + state="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json isPrerelease,isDraft \ + --jq 'if (.isPrerelease or .isDraft) then "true" else "false" end')" + if [ "$state" = true ]; then + echo "::error::$tag is a pre-release or a draft; the site renders published releases only" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Check out the released commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ steps.release.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + # abcd's highest stable vX.Y.Z release by version, never the release + # marked "latest": that mark can be moved to any release. + - name: Download abcd and its checksums + id: abcd + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tags="$(gh release list --repo intentdriven/abcd --exclude-drafts --exclude-pre-releases \ + --limit 1000 --json tagName --jq '.[].tagName')" + version="$(printf '%s\n' "$tags" | sed -n 's/^v\([0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\)$/\1/p' \ + | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1)" + ABCD_TAG="v${version}" + if ! [[ "$ABCD_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::intentdriven/abcd has no published stable vX.Y.Z release to render with" + exit 1 + fi + echo "tag=$ABCD_TAG" >> "$GITHUB_OUTPUT" + gh release download "$ABCD_TAG" --repo intentdriven/abcd \ + --pattern 'abcd-linux-amd64' --pattern 'checksums.txt' --dir "$RUNNER_TEMP" + + # The checksum is integrity only: checksums.txt sits on the same release + # page as the binary, so whoever can replace one can replace both. The + # provenance is the authenticity check: it is held to abcd's release + # workflow on main, built from main on a GitHub-hosted runner, at the + # commit the downloaded release's tag names. + - name: Verify abcd against its release manifest and its provenance + env: + GH_TOKEN: ${{ github.token }} + ABCD_TAG: ${{ steps.abcd.outputs.tag }} + run: | + set -euo pipefail + if ! [[ "$ABCD_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::the abcd release tag is not vX.Y.Z; refusing to verify it" + exit 1 + fi + cd "$RUNNER_TEMP" + grep ' abcd-linux-amd64$' checksums.txt | sha256sum -c - + commit="$(gh api "repos/intentdriven/abcd/commits/${ABCD_TAG}" --jq .sha)" + if ! [[ "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::the commit of abcd $ABCD_TAG could not be resolved; refusing to run it" + exit 1 + fi + gh attestation verify abcd-linux-amd64 --repo intentdriven/abcd \ + --cert-identity https://github.com/intentdriven/abcd/.github/workflows/release.yml@refs/heads/main \ + --source-ref refs/heads/main \ + --source-digest "$commit" \ + --deny-self-hosted-runners + install -m 0755 abcd-linux-amd64 "${RUNNER_TEMP}/abcd" + + - name: Render and check the site + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + "${RUNNER_TEMP}/abcd" site build --out site --version "${TAG#v}" --commit "$(git rev-parse --verify HEAD)" + "${RUNNER_TEMP}/abcd" lint site --out site + + - name: Pack the rendered site + run: tar -czf site.tar.gz -C site . + + - name: Hand the rendered site to the deploy job + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: site-${{ steps.release.outputs.tag }} + path: site.tar.gz + if-no-files-found: error + retention-days: 1 + + deploy: + needs: render + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: site + permissions: + contents: read + steps: + - name: Check out the released commit for the host configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.render.outputs.tag }} + persist-credentials: false + + - name: Collect the rendered site + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: site-${{ needs.render.outputs.tag }} + path: ${{ runner.temp }}/site-artifact + + # The archive is audited before a byte of it is unpacked: only relative + # paths, no parent traversal, only regular files and directories. + - name: Audit and unpack the rendered site + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/site-artifact/site.tar.gz" + while IFS= read -r member; do + [ -n "$member" ] || continue + case "$member" in + /*|..|../*|*/../*|*/..) echo "::error::refusing site.tar.gz: unsafe member '$member'"; exit 1 ;; + esac + done <<< "$(tar -tzf "$archive")" + while IFS= read -r t; do + case "$t" in + -|d|'') ;; + *) echo "::error::refusing site.tar.gz: non-regular member (type '$t')"; exit 1 ;; + esac + done <<< "$(tar -tvzf "$archive" | awk '{print substr($1,1,1)}' | sort -u)" + staging="${RUNNER_TEMP}/site-unpacked" + rm -rf "$staging" site + mkdir -p "$staging" site + tar -xzf "$archive" -C "$staging" + cp -R "$staging/." site/ + test -n "$(ls -A site)" + + - name: Deploy to Cloudflare + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy + wranglerVersion: '4.123.0' diff --git a/internal/core/site/testdata/site-workflow-github-pages.yml b/internal/core/site/testdata/site-workflow-github-pages.yml new file mode 100644 index 000000000..f6d67b935 --- /dev/null +++ b/internal/core/site/testdata/site-workflow-github-pages.yml @@ -0,0 +1,285 @@ +name: site + +# Written by `abcd site setup` (itd-2609061543533170). Re-running the verb +# rewrites nothing while this file is unchanged, and refuses a hand edit unless +# it is told to replace it. +# +# Renders this repository's site from each published release with abcd's own +# released binary — checksum- and attestation-verified before it runs — and +# deploys the rendered artefact to github-pages. The render job holds no +# credential, and neither does the deploy job: it deploys with the run's own +# short-lived token and runs nothing from the repository. +# +# A PROJECT SITE (`--project`): every page is built from what abcd already +# writes to the repository (its identity block, brief, development record and +# CHANGELOG.md), and its interface words, styles and scripts come from the +# abcd binary. The repository carries no site sources. +# +# THE SWITCH: the site publishes only while `"publish": true` stands in the +# hosting block of .abcd/site.json both at the released commit and at the +# head of main, and only while the repository is public. +# Switched off, a release builds and publishes nothing, a later push or +# dispatch included, and `abcd site setup` offers to remove this file. +# +# Three entry points, one path: +# +# release: published a release published by a person or a personal token. +# workflow_run the `release` or `auto-release` workflow finishing on +# main or on a v-tag. A release created with +# the workflow's own GITHUB_TOKEN fires no `release` +# event, so a release workflow's completion is what +# carries those releases here: `auto-release` runs on +# every push to main and calls `release` inside +# its own run, and a hand-pushed tag runs `release` with +# the tag as its head branch. A completion that +# released nothing redeploys the latest published +# release: the same bytes the site already serves, +# unless a dispatch rolled it back to an older tag, +# which the next such completion replaces with the +# latest release again. +# workflow_dispatch a redeploy by hand; an empty tag means the latest +# published release. +# +# THE ENVIRONMENT CONTRACT (the tree's record of the forge's state; `abcd site +# setup` creates both through the forge's API when it is asked to): +# +# site-render the render job. No secrets. Deployment policy: branch +# `main` and tags `v*`. +# github-pages the deploy job. Same policy. No secrets: the run's own token. +# +# The policy refuses unreviewed workflow content: a dispatch runs this file as +# it exists on the ref it was dispatched from, so a branch that is neither the +# default branch nor a release tag is refused before either job starts. +on: + release: + types: [published] + workflow_run: + workflows: [release, auto-release] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Release tag to render and deploy (vX.Y.Z). Empty means the latest published release. + required: false + type: string + default: '' + +# One production target, so one queue. A deploy cancelled between uploading +# assets and switching the route leaves the site half-moved. +concurrency: + group: site-production + cancel-in-progress: false + +permissions: + contents: read + +jobs: + render: + # A workflow_run fires for the release workflows' runs whatever triggered + # them, and a head branch is a name a fork can choose. Only a successful + # run of this repository's own release workflows, not of a pull request, + # on the default branch or a v-tag (the trigger filters no branch, because + # a tag-push run's head branch is the tag), reaches the render. Nothing + # from the triggering run is used either way: the tag is resolved from the + # repository's published releases. + if: >- + ${{ github.event_name != 'workflow_run' || ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event != 'pull_request' && + github.event.workflow_run.event != 'pull_request_target' && + github.event.workflow_run.head_repository.full_name == github.repository && + (github.event.workflow_run.head_branch == 'main' || + startsWith(github.event.workflow_run.head_branch, 'v'))) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: site-render + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + publish: ${{ steps.switch.outputs.on }} + steps: + - name: Resolve the release to render + id: release + env: + GH_TOKEN: ${{ github.token }} + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag="${EVENT_TAG:-${INPUT_TAG:-}}" + if [ -z "$tag" ]; then + tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" + fi + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then + echo "::error::the release tag is not vX.Y.Z; refusing to render it" + exit 1 + fi + state="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json isPrerelease,isDraft \ + --jq 'if (.isPrerelease or .isDraft) then "true" else "false" end')" + if [ "$state" = true ]; then + echo "::error::$tag is a pre-release or a draft; the site renders published releases only" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Check out the released commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ steps.release.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + # On only while all three hold: the repository is public (Pages from a + # private repository is public on every plan but Enterprise), and the + # switch is on both at the released commit and at the head of + # main, so a switch turned off there is not undone by a + # later push or dispatch that renders an older tag. A missing or + # malformed file reads as off. + - name: Read the project-site switch + id: switch + env: + REPO_PRIVATE: ${{ github.event.repository.private }} + run: | + set -euo pipefail + on=true + if [ "${REPO_PRIVATE:-}" != false ]; then + echo "::notice::this repository is not public, so this release publishes nothing" + on=false + fi + if ! jq -e '.hosting.publish == true' .abcd/site.json >/dev/null 2>&1; then + echo "::notice::the project site is switched off in .abcd/site.json at the released commit, so this release publishes nothing" + on=false + fi + if ! git show "origin/main:.abcd/site.json" 2>/dev/null | jq -e '.hosting.publish == true' >/dev/null 2>&1; then + echo "::notice::the project site is switched off in .abcd/site.json on main, so this release publishes nothing" + on=false + fi + echo "on=$on" >> "$GITHUB_OUTPUT" + + # abcd's highest stable vX.Y.Z release by version, never the release + # marked "latest": that mark can be moved to any release. + - name: Download abcd and its checksums + id: abcd + if: steps.switch.outputs.on == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tags="$(gh release list --repo intentdriven/abcd --exclude-drafts --exclude-pre-releases \ + --limit 1000 --json tagName --jq '.[].tagName')" + version="$(printf '%s\n' "$tags" | sed -n 's/^v\([0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\)$/\1/p' \ + | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1)" + ABCD_TAG="v${version}" + if ! [[ "$ABCD_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::intentdriven/abcd has no published stable vX.Y.Z release to render with" + exit 1 + fi + echo "tag=$ABCD_TAG" >> "$GITHUB_OUTPUT" + gh release download "$ABCD_TAG" --repo intentdriven/abcd \ + --pattern 'abcd-linux-amd64' --pattern 'checksums.txt' --dir "$RUNNER_TEMP" + + # The checksum is integrity only: checksums.txt sits on the same release + # page as the binary, so whoever can replace one can replace both. The + # provenance is the authenticity check: it is held to abcd's release + # workflow on main, built from main on a GitHub-hosted runner, at the + # commit the downloaded release's tag names. + - name: Verify abcd against its release manifest and its provenance + if: steps.switch.outputs.on == 'true' + env: + GH_TOKEN: ${{ github.token }} + ABCD_TAG: ${{ steps.abcd.outputs.tag }} + run: | + set -euo pipefail + if ! [[ "$ABCD_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::the abcd release tag is not vX.Y.Z; refusing to verify it" + exit 1 + fi + cd "$RUNNER_TEMP" + grep ' abcd-linux-amd64$' checksums.txt | sha256sum -c - + commit="$(gh api "repos/intentdriven/abcd/commits/${ABCD_TAG}" --jq .sha)" + if ! [[ "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::the commit of abcd $ABCD_TAG could not be resolved; refusing to run it" + exit 1 + fi + gh attestation verify abcd-linux-amd64 --repo intentdriven/abcd \ + --cert-identity https://github.com/intentdriven/abcd/.github/workflows/release.yml@refs/heads/main \ + --source-ref refs/heads/main \ + --source-digest "$commit" \ + --deny-self-hosted-runners + install -m 0755 abcd-linux-amd64 "${RUNNER_TEMP}/abcd" + + - name: Render and check the site + if: steps.switch.outputs.on == 'true' + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + "${RUNNER_TEMP}/abcd" site build --project --out site --version "${TAG#v}" --commit "$(git rev-parse --verify HEAD)" --base-path /widgets/ + "${RUNNER_TEMP}/abcd" lint site --project --out site --base-path /widgets/ + + - name: Pack the rendered site + if: steps.switch.outputs.on == 'true' + run: tar -czf site.tar.gz -C site . + + - name: Hand the rendered site to the deploy job + if: steps.switch.outputs.on == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: site-${{ steps.release.outputs.tag }} + path: site.tar.gz + if-no-files-found: error + retention-days: 1 + + deploy: + needs: render + if: needs.render.outputs.publish == 'true' && toJSON(github.event.repository.private) == 'false' + # The visibility is compared as JSON: a loose `== false` reads a missing + # field as false, which would read as public. + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: github-pages + permissions: + contents: read + pages: write + id-token: write + steps: + - name: Collect the rendered site + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: site-${{ needs.render.outputs.tag }} + path: ${{ runner.temp }}/site-artifact + + # The archive is audited before a byte of it is unpacked: only relative + # paths, no parent traversal, only regular files and directories. + - name: Audit and unpack the rendered site + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/site-artifact/site.tar.gz" + while IFS= read -r member; do + [ -n "$member" ] || continue + case "$member" in + /*|..|../*|*/../*|*/..) echo "::error::refusing site.tar.gz: unsafe member '$member'"; exit 1 ;; + esac + done <<< "$(tar -tzf "$archive")" + while IFS= read -r t; do + case "$t" in + -|d|'') ;; + *) echo "::error::refusing site.tar.gz: non-regular member (type '$t')"; exit 1 ;; + esac + done <<< "$(tar -tvzf "$archive" | awk '{print substr($1,1,1)}' | sort -u)" + staging="${RUNNER_TEMP}/site-unpacked" + rm -rf "$staging" site + mkdir -p "$staging" site + tar -xzf "$archive" -C "$staging" + cp -R "$staging/." site/ + test -n "$(ls -A site)" + + - name: Hand the rendered site to GitHub Pages + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/internal/core/site/timeline.go b/internal/core/site/timeline.go index 6abd34512..91e44444c 100644 --- a/internal/core/site/timeline.go +++ b/internal/core/site/timeline.go @@ -157,7 +157,7 @@ func (e *explorer) genealogySVG() (string, map[string]tlPoint) { // The first commit: everything dated before it was designed on paper. marker := "" - if fc := e.export.History.FirstCommit; fc != "" && dayNumber(fc) >= first && dayNumber(fc) <= last { + if fc := e.export.firstCommit(); fc != "" && dayNumber(fc) >= first && dayNumber(fc) <= last { fx := f1(x(fc)) marker = `` + @@ -208,7 +208,7 @@ func (e *explorer) axisRange() (int, int) { for _, r := range e.export.Releases { note(r.Date) } - note(e.export.History.FirstCommit) + note(e.export.firstCommit()) if first == 0 && last == 0 { return 0, 1 } @@ -410,9 +410,9 @@ func (e *explorer) mark(n ExportNode, p tlPoint, r float64, colour string) strin // A mark opens the record in the graph; with the graph switched off it // opens the record's own page instead, so it never points at a page the // site does not have. - href := "/" + escapeAttr(routeGraph) + "?focus=" + escapeAttr(n.ID) + href := escapeAttr(e.c.at("/" + routeGraph + "?focus=" + n.ID)) if !e.pages.graph { - href = "/" + escapeAttr(RecordRoute(n)) + href = escapeAttr(e.c.at("/" + RecordRoute(n))) } return `` + `` + escapeText(n.ID+" · "+n.Date+" · "+n.Lifecycle) + ` ` + escapeText(shortTitle(n)) + `` + @@ -553,8 +553,8 @@ func (e *explorer) supersessionList(pos map[string]tlPoint) string { if !ok { continue } - rows.WriteString(`
    • ` + - escapeText(ed.From) + ` → ` + + rows.WriteString(`
    • ` + + escapeText(ed.From) + ` → ` + escapeText(ed.To) + `` + escapeText(shortTitle(target)) + `
    • `) n++ } @@ -566,7 +566,7 @@ func (e *explorer) supersessionList(pos map[string]tlPoint) string { } sortEdgesByTarget(stubs, func(ed ExportEdge) string { return ed.From }) for _, u := range stubs { - rows.WriteString(`
    • ` + + rows.WriteString(`
    • ` + escapeText(u.From) + ` → ` + escapeText(u.To) + `` + `` + escapeText(e.c.ui.Record.NotInTree) + `
    • `) n++ diff --git a/internal/core/site/ui.go b/internal/core/site/ui.go index 8328bd04c..b8a2be1a6 100644 --- a/internal/core/site/ui.go +++ b/internal/core/site/ui.go @@ -84,6 +84,11 @@ type UI struct { // looks like a rendering fault, and one reading "v0.6.1" would be a lie. Unreleased string `json:"unreleased"` Beta string `json:"beta"` + // ManagedRecord is the line every page opens with: the site is the record + // of an abcd-managed repository, and the line links that repository + // (itd-2610091010348892). It says who the content belongs to, because the + // repository is where every word on the page comes from. + ManagedRecord string `json:"managed_record"` } // RecordNav labels the explorer's sub-navigation. @@ -300,6 +305,13 @@ func LoadUI(repoRoot, rel string) (UI, error) { if err != nil { return UI{}, err } + return decodeUI(data, rel) +} + +// decodeUI decodes an interface-string allowlist against the closed struct, +// unknown keys refused and every declared string required. rel names the file +// in a refusal. +func decodeUI(data []byte, rel string) (UI, error) { var ui UI dec := json.NewDecoder(strings.NewReader(string(data))) dec.DisallowUnknownFields() diff --git a/internal/core/surface/sentences.go b/internal/core/surface/sentences.go index a241fe6c3..0a88f596e 100644 --- a/internal/core/surface/sentences.go +++ b/internal/core/surface/sentences.go @@ -230,6 +230,19 @@ var sentences = map[string]string{ "abcd dashboard stop": "Stop the dashboard after checking its process is the one start launched: " + "Writes the run file's removal; refuses when another start or stop holds the lock.", + "abcd session": "Set up live brief sessions, start one here, or run, deploy or write the team's relay and page: " + + "Writes nothing bare; refuses an unknown sub-verb.", + "abcd session blueprint": "Write the team relay's Render blueprint and Dockerfile, pinned to a release: " + + "Writes render.yaml and .abcd/relay/; refuses an edited file or no GitHub origin.", + "abcd session page": "Write the brief session's static page and its editor bundle into a directory: " + + "Writes only the page's six files; refuses a directory holding anything else.", + "abcd session setup": "Set up live brief sessions with the team's own GitHub sign-in app: " + + "Writes its client id, the answer and the blueprint; refuses no GitHub origin or no answer.", + "abcd session start": "Start a live brief session on this computer, over your tailnet: " + + "Writes only an answer it asks; refuses no recorded GitHub App, no Tailscale or no GitHub origin.", + "abcd session serve": "Run the brief session relay in the foreground, on loopback by default: " + + "Writes nothing; refuses a bad --listen, or no repository to serve beyond loopback.", + "abcd inbox": "List the reports managed repositories filed back to abcd, newest first: " + "Writes nothing; refuses any argument.", "abcd inbox promote": "File one report as a capture in abcd's own ledger: " + diff --git a/internal/reachaudit/testdata/core-unreached.txt b/internal/reachaudit/testdata/core-unreached.txt index ed21bb51b..b6690eead 100644 --- a/internal/reachaudit/testdata/core-unreached.txt +++ b/internal/reachaudit/testdata/core-unreached.txt @@ -116,12 +116,10 @@ internal/core/site.LinkDefinitions internal/core/site.LoadAuthorship internal/core/site.LoadBaseline internal/core/site.LoadBibliography -internal/core/site.LoadManifest internal/core/site.LoadPrinciples internal/core/site.LoadRepoMeta internal/core/site.LoadUI internal/core/site.PrinciplesDir -internal/core/site.Providers internal/core/site.RecordRoute internal/core/spec.Validate internal/core/spec.WithStoreLock diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index ef85b4271..83077a5e5 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -260,8 +260,9 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { // table is accepted only on an answer, so --yes names them as well; and so // is the drain eligibility record (ruling BX2), which decides what an // unattended agent may change; and so is the README question - // (spc-2610031704338417), which only the owner answers. - want := "git_identity.unpinned readme.offered oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" + // (spc-2610031704338417), which only the owner answers, and the live + // brief session question (spc-2610141709430146 step 6). + want := "git_identity.unpinned readme.offered sessions.offered oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" if strings.Join(res.OptionalSkipped, " ") != want { t.Fatalf("optional_skipped = %v, want [%s]\n%s", res.OptionalSkipped, want, jsonOut) } diff --git a/internal/surface/cli/ahoy_sessions_test.go b/internal/surface/cli/ahoy_sessions_test.go new file mode 100644 index 000000000..e32e7c66f --- /dev/null +++ b/internal/surface/cli/ahoy_sessions_test.go @@ -0,0 +1,89 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/question" + "github.com/intentdriven/abcd/internal/gittest" +) + +// The live brief session questions at the CLI's doors (spc-2610141709430146 +// step 6). + +// TestAhoyDetectionCarriesTheSessionsQuestions: while the live-session +// question is open, the detection the plugin page reads carries both +// questions as the host's question tool takes them, each alone, with its +// current setting and change-later line; --live-sessions answers it, and an +// answered question is no longer carried. +func TestAhoyDetectionCarriesTheSessionsQuestions(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t).Root() + t.Chdir(repo) + if _, err := runCLIStdinErr(t, "", "ahoy", "install", "--yes", "--adopt", "--visibility", "private", + "--docs-target", "skip", "--scan-deep", "false", "--json"); err != nil { + t.Fatalf("install: %v", err) + } + var out struct { + Sessions *ahoy.SessionsView `json:"sessions"` + SessionsTool []*hostQuestionCall `json:"sessions_tool"` + Skipped []string `json:"optional_skipped"` + } + raw := runCLI(t, "ahoy", "--json") + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("%v\n%s", err, raw) + } + if out.Sessions == nil || len(out.SessionsTool) != 2 { + t.Fatalf("no live-session questions while the question is open:\n%s", raw) + } + for i, call := range out.SessionsTool { + if len(call.Questions) != 1 { + t.Fatalf("call %d holds %d questions", i+1, len(call.Questions)) + } + q := call.Questions[0] + if !strings.Contains(q.Question, question.Default.NowPrefix) || !strings.Contains(q.Question, "--live-sessions") { + t.Errorf("call %d lacks its current setting or change-later line:\n%s", i+1, q.Question) + } + last := q.Options[len(q.Options)-1].Label + if last != question.Default.LaterLabels[0] { + t.Errorf("call %d's last option is %q", i+1, last) + } + } + if !strings.Contains(out.SessionsTool[0].Questions[0].Question, "Set up live brief sessions?") || + !strings.Contains(out.SessionsTool[1].Questions[0].Question, "Where may sessions run?") { + t.Errorf("the questions are not the two setup asks:\n%s", raw) + } + + text, err := runCLIStdinErr(t, "", "ahoy", "install", "--yes", "--adopt") + if err != nil { + t.Fatalf("install: %v\n%s", err, text) + } + if !strings.Contains(string(text), "--live-sessions "+ahoy.LiveSessionsChoices) { + t.Errorf("a run that skipped the question does not name the flag:\n%s", text) + } + + if got, err := runCLIStdinErr(t, "", "ahoy", "install", "--yes", "--adopt", "--live-sessions", "everywhere"); exitCode(err) != 2 || + !strings.Contains(err.Error(), "local, cloud, both or none") { + t.Errorf("an unknown answer: exit %d, %v\n%s", exitCode(err), err, got) + } + if got, err := runCLIStdinErr(t, "", "ahoy", "install", "--yes", "--adopt", "--live-sessions", "both"); err != nil || + !strings.Contains(string(got), "sessions.live: not set -> both") { + t.Fatalf("install --live-sessions both: %v\n%s", err, got) + } + cfg, err := os.ReadFile(filepath.Join(repo, ".abcd", "config.json")) + if err != nil || !strings.Contains(string(cfg), `"live": "both"`) { + t.Fatalf("config.json does not record the answer (%v):\n%s", err, cfg) + } + out.Sessions, out.SessionsTool = nil, nil + raw = runCLI(t, "ahoy", "--json") + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.Sessions != nil || out.SessionsTool != nil { + t.Errorf("an answered question is still carried:\n%s", raw) + } +} diff --git a/internal/surface/cli/barerender.go b/internal/surface/cli/barerender.go index e37e3698f..ae4544232 100644 --- a/internal/surface/cli/barerender.go +++ b/internal/surface/cli/barerender.go @@ -52,6 +52,10 @@ var bareRenderExceptions = map[string]string{ "a terminal and refuses (exit 2) anywhere else", "scribe": "a parent whose sub-verbs act on a named reading run or on a scribe's " + "returned output; with no operand it has no state of its own to render, so bare prints its sub-verbs", + "session": "a parent of serve, which runs the relay in the foreground, blueprint, " + + "which writes the team relay's files, and page, which writes the session's static " + + "page; a running relay's sessions live in that " + + "process's memory alone, so there is no state to render and bare prints its sub-verbs", "statusline": "its row belongs to a managed repository: there bare renders it with or " + "without the payload (TestStatuslineEmptyStdinStillRendersTheBadge), and anywhere else, " + "this test's scratch repository included, abcd has no row and bare prints nothing of " + diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index fbf20454c..fc9ae00b1 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -414,6 +414,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newMemoryCommand(&asJSON)) root.AddCommand(newInboxCommand(&asJSON)) root.AddCommand(newDashboardCommand(&asJSON)) + root.AddCommand(newSessionCommand(&asJSON)) root.AddCommand(newStatuslineCommand(&asJSON)) root.AddCommand(newAhoyCommand(&asJSON)) @@ -3925,6 +3926,11 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { out.ReadmeTool = append(out.ReadmeTool, hostCall(a)) } } + if res.Sessions != nil { + for _, a := range res.Sessions.Asks { + out.SessionsTool = append(out.SessionsTool, hostCall(a)) + } + } return render(cmd.OutOrStdout(), *asJSON, out, func(w io.Writer) { fmt.Fprintf(w, "abcd ahoy — %s\n", res.FolderKind) fmt.Fprintf(w, " plugin root: %s\n", res.PluginRootStatus) @@ -4035,6 +4041,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { answersPath string answeredIn string readmeParts string + liveSessions string ) installCmd := &cobra.Command{ Use: "install", @@ -4056,6 +4063,14 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { } opts.ReadmeParts = readmeParts } + // The live brief session answer (spc-2610141709430146 step 6): + // refused, naming the answers, before anything is written. + if cmd.Flags().Changed("live-sessions") { + if perr := ahoy.ParseLiveSessions(liveSessions); perr != nil { + return &exitError{Code: 2, Msg: "abcd ahoy install: " + perr.Error() + "; nothing was written"} + } + opts.LiveSessions = liveSessions + } opts.Outbound = outbound opts.OnlyWaiting = onlyWaiting(cmd) opts.VerifyProvenance = ahoyReleaseProvenance @@ -4180,6 +4195,9 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { default: fmt.Fprint(w, " the README question is asked only of a person at a terminal, without --yes: run `abcd ahoy install` there and answer it, or answer it with --readme-parts "+ahoy.ReadmePartsChoices+"\n") } + if slices.Contains(res.OptionalSkipped, ahoy.SessionsOfferGapID) { + fmt.Fprint(w, " the live brief session question is asked only of a person at a terminal, without --yes: run `abcd ahoy install` there and answer it, or answer it with --live-sessions "+ahoy.LiveSessionsChoices+"\n") + } } }); rerr != nil { return rerr @@ -4214,6 +4232,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { installCmd.Flags().StringSliceVar(&installTools, "install-tool", nil, "answer yes to installing this missing tool (repeatable): the answer a host's question tool relays; without it a tool is installed only on an answer typed at a terminal, never on the approve-everything flag, a piped answer or CI") installCmd.Flags().StringVar(&answersPath, "answers", "", "answer the setup questions from this answers file (JSON: schema_version, interview \"setup\", answers by question id), wherever the install runs; a question the file and no flag answers refuses with exit 2, naming its id, the flag and the file key") installCmd.Flags().StringVar(&readmeParts, "readme-parts", "", "answer the README question: the parts of README.md abcd keeps current, comma-separated from licence, links, contents, release-badge, build-badge and abcd-badge, or none, which answers no and is not asked again; stored in .abcd/config.json and honoured in any run, the approve-everything flag included") + installCmd.Flags().StringVar(&liveSessions, "live-sessions", "", "answer the live brief session question: where sessions may run, local, cloud or both, or none, which answers no and is not asked again; stored in .abcd/config.json as sessions.live and honoured in any run, the approve-everything flag included; abcd session setup then does the setting up") installCmd.Flags().StringVar(&answeredIn, "answered-in", interview.Terminal, "where an answers-file entry that names no place was answered: Terminal, or the host whose question tool asked it, as its plugin page passes; recorded with each answer") addOnlyWaitingFlag(installCmd) ahoyCmd.AddCommand(installCmd) @@ -4549,6 +4568,8 @@ func optionalSkipReason(id string) string { return "an agent tool's own conventions file that only repeats AGENTS.md is removed from the project only against a prompt answered at a terminal" case ahoy.ReadmeOfferGapID: return "which parts of README.md abcd keeps current is the owner's choice, so abcd writes none until --readme-parts or the README question answers it" + case ahoy.SessionsOfferGapID: + return "whether the team edits its brief live, and where sessions may run, is the owner's choice, so abcd records nothing until --live-sessions or the question answers it" } return "" } diff --git a/internal/surface/cli/helpgroups.go b/internal/surface/cli/helpgroups.go index 1853a2860..49d37baa5 100644 --- a/internal/surface/cli/helpgroups.go +++ b/internal/surface/cli/helpgroups.go @@ -111,6 +111,7 @@ var helpPlacements = map[string]helpPlacement{ // The person's groups. "ahoy": {group: groupSetUp}, "dashboard": {group: groupSetUp}, + "session": {group: groupSetUp}, "update": {group: groupSetUp}, "build": {group: groupRecords}, "capture": {group: groupRecords}, diff --git a/internal/surface/cli/helpgroups_test.go b/internal/surface/cli/helpgroups_test.go index bb3995714..5df44fd66 100644 --- a/internal/surface/cli/helpgroups_test.go +++ b/internal/surface/cli/helpgroups_test.go @@ -88,7 +88,7 @@ func TestRootHelpListsThePersonsGroups(t *testing.T) { // embargo joins release by spc-2610120557432880's step 10, and status // joins records by itd-2609151838312703's decision 21. for group, want := range map[string][]string{ - "Set-up:": {"ahoy", "completion", "dashboard", "help", "update"}, + "Set-up:": {"ahoy", "completion", "dashboard", "help", "session", "update"}, "Records:": {"build", "capture", "decide", "ideate", "intent", "land", "library", "status"}, "Checks:": {"lab", "reading"}, "Portability:": {"disembark", "embark"}, diff --git a/internal/surface/cli/session.go b/internal/surface/cli/session.go new file mode 100644 index 000000000..84aaebcc1 --- /dev/null +++ b/internal/surface/cli/session.go @@ -0,0 +1,971 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/intentdriven/abcd/internal/adapter/tailscale" + "github.com/intentdriven/abcd/internal/core" + "github.com/intentdriven/abcd/internal/core/ahoy" + coresession "github.com/intentdriven/abcd/internal/core/session" + "github.com/intentdriven/abcd/internal/core/update" + "github.com/intentdriven/abcd/internal/gitutil" + "github.com/intentdriven/abcd/internal/surface/session" + "github.com/intentdriven/abcd/internal/surface/session/web" + "github.com/intentdriven/abcd/internal/termsafe" + "github.com/spf13/cobra" +) + +// The environment variables the relay reads the team's GitHub App from. The +// secret is read by name and never printed; the client id is public. +const ( + sessionSecretEnv = "ABCD_SESSION_GITHUB_CLIENT_SECRET" + sessionClientIDEnv = "ABCD_SESSION_GITHUB_CLIENT_ID" +) + +// sessionLegacyAppTypeEnv once chose between a GitHub App and an OAuth app. +// The relay now signs in through a GitHub App alone, so the variable may be +// unset or say github-app; anything else is a deployment still set up for an +// OAuth app, refused rather than started with a sign-in it cannot honour. +const sessionLegacyAppTypeEnv = "ABCD_SESSION_GITHUB_APP_TYPE" + +// sessionOriginsEnv names the page origins serve admits when no --origin is +// given: a comma list. The blueprint writes it. +const sessionOriginsEnv = coresession.OriginsEnv + +// sessionOrigins is the page origins serve admits: every --origin, or else +// each entry of ABCD_SESSION_ORIGINS, and the name of where they came from. +func sessionOrigins(flags []string, env string) ([]string, string) { + if len(flags) > 0 { + return flags, "--origin" + } + var out []string + for _, e := range strings.Split(env, ",") { + if e = strings.TrimSpace(e); e != "" { + out = append(out, e) + } + } + return out, sessionOriginsEnv +} + +// sessionReposEnv names the repositories the relay serves, besides --repo and +// --owner: a comma list whose owner/name entries are repositories and whose +// bare entries are owners. It is not a secret; the blueprint writes it. +const sessionReposEnv = coresession.ReposEnv + +// sessionOriginRepo is the owner/name the checkout at cwd's origin names on +// github.com. It is a package var so a test can answer for it. +var sessionOriginRepo = ahoy.GitHubRepo + +// sessionReleaseOrigin is where the blueprint reads a release's checksums. +const sessionReleaseOrigin = "https://github.com/intentdriven/abcd" + +// newSessionReleaseFetcher builds the fetcher the blueprint reads a release's +// checksums.txt through. It is a package var so a test serves the release from +// memory: no test reaches the network. +var newSessionReleaseFetcher = func() (coresession.AssetFetcher, error) { + return update.NewReleaseAssets(sessionReleaseOrigin) +} + +// newSessionCommand builds the `session` verb, the front door onto the brief +// session's relay and page (itd-2610091016262335, spc-2610141709430146 steps +// 1 to 3). +func newSessionCommand(asJSON *bool) *cobra.Command { + cmd := &cobra.Command{ + Use: "session", + Long: "Set up live brief sessions for this project, start one on this computer, run\n" + + "the relay a team's cloud sessions go through, write the blueprint that deploys\n" + + "that relay to the team's own Render account, or write the session's static page\n" + + "for the team to serve.\n" + + "The relay holds each session's edits in memory only, as opaque bytes, and drops\n" + + "them when the host closes the session or nobody has been connected for half an\n" + + "hour; it writes nothing to disk and logs nothing. abcd runs no relay of its own:\n" + + "the team runs it, on the host's computer reached over Tailscale Serve\n" + + "(`abcd session start`), or on the team's own relay (`abcd session serve`).\n\n" + + "Bare `abcd session` prints this help and writes nothing.", + Args: cobra.NoArgs, + RunE: helpRunE, + } + cmd.AddCommand(newSessionSetupCommand(asJSON), newSessionStartCommand(asJSON), newSessionServeCommand(asJSON), newSessionBlueprintCommand(asJSON), newSessionPageCommand(asJSON)) + return cmd +} + +// sessionStart is the one line, or JSON object, serve prints once it listens. +type sessionStart struct { + URL string `json:"url"` + Loopback bool `json:"loopback"` + Proxy bool `json:"behind_proxy"` + SignIn bool `json:"sign_in"` + Repos []string `json:"repos"` + Owners []string `json:"owners"` + AnyRepo bool `json:"any_repository"` + Hint string `json:"hint"` +} + +// sessionAllowlist gathers the repositories serve is told to serve: every +// --repo, every --owner, and every entry of ABCD_SESSION_REPOS, refusing one +// that is not owner/name or an owner. +func sessionAllowlist(repos, owners []string, env string) ([]string, []string, error) { + var outRepos, outOwners []string + for _, r := range repos { + if !coresession.ValidRepo(r) { + return nil, nil, fmt.Errorf("--repo %s is not a repository: give owner/name", strconv.Quote(r)) + } + outRepos = append(outRepos, r) + } + for _, o := range owners { + if !coresession.ValidOwner(o) { + return nil, nil, fmt.Errorf("--owner %s is not a GitHub user or organisation", strconv.Quote(o)) + } + outOwners = append(outOwners, o) + } + if strings.TrimSpace(env) == "" { + return outRepos, outOwners, nil + } + for _, e := range strings.Split(env, ",") { + e = strings.TrimSpace(e) + switch { + case strings.Contains(e, "/") && coresession.ValidRepo(e): + outRepos = append(outRepos, e) + case !strings.Contains(e, "/") && coresession.ValidOwner(e): + outOwners = append(outOwners, e) + default: + return nil, nil, fmt.Errorf("%s holds %s, which is neither owner/name nor an owner", sessionReposEnv, strconv.Quote(e)) + } + } + return outRepos, outOwners, nil +} + +// sessionServed is what serve serves: the list it is given, or, with none, +// the origin repository of the checkout at cwd on loopback (every repository +// outside one), and a refusal beyond loopback. +func sessionServed(loopback bool, repos, owners []string, env, cwd string) ([]string, []string, error) { + r, o, err := sessionAllowlist(repos, owners, env) + if err != nil || len(r) > 0 || len(o) > 0 { + return r, o, err + } + if !loopback { + return nil, nil, errors.New("listening beyond this computer, the relay serves only the repositories named with --repo, --owner or " + sessionReposEnv + "; name at least one") + } + if cwd != "" { + if origin, err := sessionOriginRepo(cwd); err == nil && coresession.ValidRepo(origin) { + return []string{origin}, nil, nil + } + } + return nil, nil, nil +} + +// sessionTailnetName is this computer's tailnet name, as Tailscale's own +// read-only status reports it. It is a package var so a test can answer for +// it: no test runs Tailscale. +var sessionTailnetName = func(ctx context.Context, root string) (string, error) { + bin, err := tailscale.Resolve(exec.LookPath, tailscale.BundledCommands, root) + if err != nil { + return "", err + } + self, err := tailscale.New(bin).Status(ctx) + if err != nil { + return "", err + } + return self.Name, nil +} + +// sessionLocal is the start line, or JSON object, `session start` prints once +// it listens. +type sessionLocal struct { + URL string `json:"url"` + Link string `json:"link"` + Repo string `json:"repo"` + Tailnet string `json:"tailnet"` + ServeStep string `json:"serve_step"` + SignIn string `json:"sign_in"` +} + +// sessionDefaultPort is where a local session listens unless --port names +// another: the relay's own default. +const sessionDefaultPort = 8787 + +func newSessionStartCommand(asJSON *bool) *cobra.Command { + var port int + var clientID string + c := &cobra.Command{ + Use: "start", + Long: "Start a live brief session on this computer, for a project whose brief should\n" + + "not leave the team's tailnet. It runs the session relay on loopback and serves the\n" + + "session page from it, so nothing goes to abcdesign.app, and prints the one\n" + + "Tailscale step that shares it on your tailnet and the link to open.\n\n" + + "It serves sessions for this checkout's origin repository on github.com. Team\n" + + "members sign in with GitHub's device flow: the page shows a short code to confirm\n" + + "on github.com, so no secret sits on this computer. The GitHub App's public client\n" + + "id comes from --client-id, " + sessionClientIDEnv + ", or\n" + + ".abcd/relay/" + coresession.AppFile + ", which repository setup writes; without\n" + + "one it refuses, since nobody could sign in, and a person at a terminal is first\n" + + "asked the live brief session question while it is unanswered. Anyone else joins\n" + + "as a guest with a\n" + + "name, and suggests changes.\n\n" + + "It asks Tailscale for this computer's tailnet name and changes nothing in\n" + + "Tailscale. It answers only that name and its own loopback address, as hosts and\n" + + "as page origins. People outside your tailnet join as guests once you share this\n" + + "computer with them in Tailscale. Never Funnel.\n\n" + + "It runs in the foreground until interrupted; every session ends with it.\n" + + "Exit 2 on a refusal, with nothing started.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) (err error) { + // A question drawn at a terminal and stopped by Ctrl-C ends the verb (exit 130). + defer endOnStop(&err) + const prefix = "abcd session start: " + refuse := func(msg string) error { return &exitError{Code: 2, Msg: prefix + msg + " (nothing started)"} } + if port < 0 || port > 65535 { + return refuse("--port " + strconv.Itoa(port) + " is not 0 to 65535") + } + cwd, err := os.Getwd() + if err != nil { + return err + } + root, err := gitutil.CheckoutRoot(cwd, "the live session's checkout") + if err != nil { + return refuse(err.Error()) + } + repo, err := sessionOriginRepo(root) + if err != nil || !coresession.ValidRepo(repo) { + return refuse("a live session serves this checkout's repository, and its origin does not name one on github.com") + } + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv(sessionClientIDEnv)) + } + if clientID == "" { + id, err := coresession.ReadAppClientID(root) + if err != nil { + return refuse(err.Error()) + } + clientID = id + } + if clientID == "" { + return refuse("sign-in is not set up for this project: no GitHub App is recorded in .abcd/relay/" + coresession.AppFile + + "; " + sessionSetupHint(cmd, root) + ", or name the app's client id with --client-id") + } + if !coresession.ValidClientID(clientID) { + return refuse("--client-id " + strconv.Quote(clientID) + " is not a GitHub App's client id") + } + name, err := sessionTailnetName(cmd.Context(), root) + name = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), ".")) + if err != nil || name == "" || strings.ContainsAny(name, "/:@ ") { + why := "Tailscale names no tailnet name for this computer" + if err != nil { + why = err.Error() + } + return refuse("a local session is reached over Tailscale, and " + why) + } + ln, err := session.Listen(netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), uint16(port))) + if err != nil { + return refuse("cannot listen on 127.0.0.1:" + strconv.Itoa(port) + ": " + err.Error()) + } + bound := ln.Addr().String() + boundPort := bound[strings.LastIndex(bound, ":")+1:] + // The page and the relay are one server: its origin is the + // tailnet name Serve answers on, or this computer's own address. + srv, err := session.New(session.Config{ + Origins: []string{"https://" + name, "http://127.0.0.1:" + boundPort, "http://localhost:" + boundPort}, + Hosts: []string{name, "127.0.0.1:" + boundPort, "localhost:" + boundPort}, + LoopbackOnly: true, + Repos: []string{repo}, + ClientID: clientID, + ServePage: true, + }) + if err != nil { + _ = ln.Close() + return refuse(err.Error()) + } + local := sessionLocal{ + URL: "http://" + bound, + Link: "https://" + name + "/" + repo + "/brief/", + Repo: repo, + Tailnet: name, + ServeStep: "tailscale serve --bg " + boundPort, + SignIn: "device", + } + _ = render(cmd.OutOrStdout(), *asJSON, local, func(w io.Writer) { + fmt.Fprintf(w, "abcd session start: a live brief session for %s is ready on this computer\n", termsafe.Sanitize(repo)) + fmt.Fprintln(w, " 1. share it on your tailnet, once (Tailscale keeps it until you turn it off):") + fmt.Fprintf(w, " %s\n", local.ServeStep) + fmt.Fprintln(w, " 2. open this link, sign in with GitHub and start the session:") + fmt.Fprintf(w, " %s\n", termsafe.Sanitize(local.Link)) + fmt.Fprintln(w, " the page then shows a code and a QR code to invite people on your tailnet") + fmt.Fprintln(w, " people outside your tailnet can join as guests once you share this computer with them in Tailscale") + fmt.Fprintln(w, " stop it with Ctrl-C; every session ends with it") + }) + if err := srv.Run(cmd.Context(), ln); err != nil && !errors.Is(err, cmd.Context().Err()) { + return fmt.Errorf("%s%w", prefix, err) + } + return nil + }, + } + c.Flags().IntVar(&port, "port", sessionDefaultPort, "the port on this computer to listen on (0 lets the system choose)") + c.Flags().StringVar(&clientID, "client-id", "", "the team's GitHub App's public client id (default: "+sessionClientIDEnv+", then .abcd/relay/"+coresession.AppFile+")") + return c +} + +func newSessionServeCommand(asJSON *bool) *cobra.Command { + var listen, clientID string + var origins, repos, owners []string + var behindProxy bool + c := &cobra.Command{ + Use: "serve", + Long: "Run the brief session relay in the foreground until interrupted. It listens on\n" + + "this computer alone (" + session.DefaultListen + ") unless --listen names another address,\n" + + "which is the second, explicit choice: the team's own relay deployment listens on\n" + + "0.0.0.0 behind its host's proxy, with --behind-proxy. On a person's own machine,\n" + + "teammates reach it through Tailscale Serve, on the tailnet only; the start line\n" + + "prints the command. Never Funnel: a guest outside the tailnet joins through the\n" + + "team's relay instead.\n\n" + + "It serves sessions only for the repositories it is given: each --repo owner/name,\n" + + "every repository of each --owner, and each entry of " + sessionReposEnv + ", a\n" + + "comma list of either. Nobody signs in against, or hosts, any other. Listening\n" + + "beyond this computer it refuses to start without one; on loopback it serves this\n" + + "checkout's origin repository, or any repository outside a GitHub checkout.\n\n" + + "It serves the session WebSocket at /session, the sign-in's callback at\n" + + "/auth/callback (the callback URL the team's GitHub App lists) and its exchange\n" + + "at /auth/token, the GitHub App's public client id at /config and a health check\n" + + "at /healthz. It admits a browser only from https://abcdesign.app or a loopback\n" + + "page, or, when --origin is given, from exactly the origins it names, and otherwise, when\n" + + sessionOriginsEnv + " is set, from exactly the origins that comma list names.\n" + + "An entry for a page served below a base path names it, as abcd session page\n" + + "--base-path wrote it (https://host/base/); the callback forwards a sign-in only\n" + + "to the page's own base path, the root for an entry that names none.\n" + + "Sign-in goes through the team's GitHub App only, and nobody pastes a token: the\n" + + "relay takes only a GitHub App user access token. Its client id is read from\n" + + "--client-id or " + sessionClientIDEnv + ", and its secret from\n" + + sessionSecretEnv + "; with both, members sign in with GitHub's web\n" + + "flow. Without both, sign-in is not offered and only guests can join: GitHub's\n" + + "device flow, which needs no secret, belongs to abcd session start alone, whose\n" + + "relay serves its own session page.\n" + + sessionLegacyAppTypeEnv + " set to anything but github-app is refused: the\n" + + "relay takes no OAuth app.\n\n" + + "Exit 2 on a refusal, with nothing started.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + const prefix = "abcd session serve: " + addr, err := session.ParseListen(listen) + if err != nil { + return &exitError{Code: 2, Msg: prefix + err.Error() + " (nothing started)"} + } + loopback := addr.Addr().IsLoopback() + cwd, _ := os.Getwd() + allowRepos, allowOwners, err := sessionServed(loopback, repos, owners, os.Getenv(sessionReposEnv), cwd) + if err != nil { + return &exitError{Code: 2, Msg: prefix + err.Error() + " (nothing started)"} + } + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv(sessionClientIDEnv)) + } + if v := strings.TrimSpace(os.Getenv(sessionLegacyAppTypeEnv)); v != "" && v != session.AppTypeGitHubApp { + return &exitError{Code: 2, Msg: prefix + sessionLegacyAppTypeEnv + " is " + strconv.Quote(v) + + "; the relay signs in through a GitHub App only: register one with Contents and Pull requests read and write and Metadata read, and unset the variable (nothing started)"} + } + admit, admitFrom := sessionOrigins(origins, os.Getenv(sessionOriginsEnv)) + // On loopback alone, the relay is the host's own machine behind + // Tailscale Serve, and a visitor is counted by the tailnet address + // Serve forwards. + srv, err := session.New(session.Config{ + Origins: admit, + BehindProxy: behindProxy, + LoopbackOnly: loopback, + Repos: allowRepos, + Owners: allowOwners, + ClientID: clientID, + ClientSecret: os.Getenv(sessionSecretEnv), + }) + if err != nil { + return &exitError{Code: 2, Msg: prefix + admitFrom + " " + err.Error() + " (nothing started)"} + } + ln, err := session.Listen(addr) + if err != nil { + return &exitError{Code: 2, Msg: prefix + "cannot listen on " + addr.String() + ": " + err.Error() + " (nothing started)"} + } + bound := ln.Addr().String() + start := sessionStart{ + URL: "http://" + bound, + Loopback: loopback, + Proxy: behindProxy, + SignIn: clientID != "" && os.Getenv(sessionSecretEnv) != "", + Repos: append([]string{}, allowRepos...), + Owners: append([]string{}, allowOwners...), + AnyRepo: len(allowRepos) == 0 && len(allowOwners) == 0, + } + if loopback { + port := bound[strings.LastIndex(bound, ":")+1:] + start.Hint = "teammates on your tailnet reach it once you run `tailscale serve --bg " + port + + "`; open the session page with the https address that prints" + } else { + start.Hint = "listening beyond this computer, on an address chosen with --listen" + } + _ = render(cmd.OutOrStdout(), *asJSON, start, func(w io.Writer) { + where := "this computer alone (loopback)" + if !loopback { + where = "every address --listen names, beyond this computer" + } + fmt.Fprintf(w, "abcd session serve: relay listening at %s, on %s\n", termsafe.Sanitize(start.URL), where) + fmt.Fprintf(w, " %s\n", start.Hint) + if start.AnyRepo { + fmt.Fprintln(w, " serves sessions for every repository (loopback only; name one with --repo)") + } else { + names := append([]string{}, start.Repos...) + for _, o := range start.Owners { + names = append(names, "every repository of "+o) + } + fmt.Fprintf(w, " serves sessions for %s\n", termsafe.Sanitize(strings.Join(names, ", "))) + } + if start.SignIn { + fmt.Fprintln(w, " sign-in: the team's GitHub App, through GitHub's web flow") + } else { + fmt.Fprintf(w, " sign-in is not set up (set --client-id and %s); only guests can join\n", sessionSecretEnv) + } + fmt.Fprintln(w, " stop it with Ctrl-C; every session it holds ends with it") + }) + if err := srv.Run(cmd.Context(), ln); err != nil && !errors.Is(err, cmd.Context().Err()) { + return fmt.Errorf("%s%w", prefix, err) + } + return nil + }, + } + c.Flags().StringVar(&listen, "listen", session.DefaultListen, "the address to listen on: an IP address (or localhost) and a port; anything but loopback is listening beyond this computer") + c.Flags().BoolVar(&behindProxy, "behind-proxy", false, "trust the last X-Forwarded-For entry as the client's address; only for the team relay behind its host's proxy") + c.Flags().StringArrayVar(&origins, "origin", nil, "a page origin to admit, as scheme://host[:port], followed by the page's base path when it is served below one, as https://host/base/ (repeatable; also "+sessionOriginsEnv+"); given, it replaces the default of https://abcdesign.app and loopback pages") + c.Flags().StringArrayVar(&repos, "repo", nil, "a repository to serve sessions for, as owner/name (repeatable; also "+sessionReposEnv+")") + c.Flags().StringArrayVar(&owners, "owner", nil, "a GitHub user or organisation whose every repository to serve sessions for (repeatable)") + c.Flags().StringVar(&clientID, "client-id", "", "the team's GitHub sign-in app's public client id (default: "+sessionClientIDEnv+")") + return c +} + +func newSessionBlueprintCommand(asJSON *bool) *cobra.Command { + var release, relayOrigin string + var pageOrigins []string + c := &cobra.Command{ + Use: "blueprint", + Long: "Write the team's own relay deployment into this checkout: render.yaml, a Render\n" + + "Blueprint declaring one free web service, at the repository's root, where\n" + + "Render's Deploy button reads it, and .abcd/relay/Dockerfile,\n" + + "which downloads the released abcd binary for linux/amd64 and checks it against\n" + + "the SHA-256 that release's checksums.txt publishes. The checksum is read from the\n" + + "release page when the files are written (the one network read this verb makes,\n" + + "named on stderr) and pinned in the Dockerfile. The relay serves sessions for this\n" + + "checkout's origin repository on github.com, named in render.yaml as\n" + + sessionReposEnv + ", a plain value; it refuses a checkout without one.\n\n" + + "render.yaml also names, in " + sessionOriginsEnv + ", the page origins the relay\n" + + "admits: each --origin, an https origin followed by the page's base path when it\n" + + "is served below one, or " + coresession.DefaultPageOrigin + " by default, and,\n" + + "once repository setup has recorded the team's GitHub App in\n" + + ".abcd/relay/" + coresession.AppFile + ", its public client id, so Render's form asks\n" + + "only for the secret. A root render.yaml the team wrote is never touched: the\n" + + "Blueprint then goes to .abcd/relay/render.yaml, picked by its Blueprint Path on\n" + + "Render's New Blueprint form.\n\n" + + "While the live brief session question is unanswered, a person at a terminal is\n" + + "asked it first, as repository setup asks it.\n\n" + + "--relay-origin names the deployed relay's https origin, once Render has given it\n" + + "one; the verb then also writes .abcd/relay/relays.json, naming it as the relay\n" + + "this repository trusts, which the session page reads from the repository, and\n" + + "prints the callback URL the team's GitHub App must list for the relay's sign-in:\n" + + "the relay's origin followed by " + coresession.RelayCallbackPath + ".\n\n" + + "--release names the release to pin; it defaults to this abcd's own version when\n" + + "that is a release. A file left exactly as abcd wrote it is replaced; one that was\n" + + "edited, or that abcd did not write, is never overwritten, and nothing is written.\n\n" + + "Exit 2 on a refusal, with nothing written.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) (err error) { + // A question drawn at a terminal and stopped by Ctrl-C ends the verb (exit 130). + defer endOnStop(&err) + const prefix = "abcd session blueprint: " + if release == "" { + release = core.Version + if !coresession.ValidReleaseTag(release) { + return &exitError{Code: 2, Msg: prefix + "this abcd (" + release + ") is not a release; name one with --release vMAJOR.MINOR.PATCH (nothing written)"} + } + } + if !coresession.ValidReleaseTag(release) { + return &exitError{Code: 2, Msg: prefix + strconv.Quote(release) + " is not a release tag; name one as vMAJOR.MINOR.PATCH (nothing written)"} + } + if relayOrigin != "" { + if _, err := coresession.NormalizeRelayOrigin(relayOrigin); err != nil { + return &exitError{Code: 2, Msg: prefix + "--relay-origin " + err.Error() + " (nothing written)"} + } + } + for _, o := range pageOrigins { + if _, err := coresession.NormalizePageOrigin(o); err != nil { + return &exitError{Code: 2, Msg: prefix + "--origin " + err.Error() + " (nothing written)"} + } + } + cwd, err := os.Getwd() + if err != nil { + return err + } + root, err := gitutil.CheckoutRoot(cwd, "the relay blueprint's checkout") + if err != nil { + return &exitError{Code: 2, Msg: prefix + err.Error() + " (nothing written)"} + } + if !ahoy.Managed(root) { + return &exitError{Code: 2, Msg: prefix + "this checkout is not one abcd manages; run `abcd ahoy install` first (nothing written)"} + } + served, err := sessionOriginRepo(root) + if err != nil || !coresession.ValidRepo(served) { + return &exitError{Code: 2, Msg: prefix + "the relay serves this checkout's repository, and its origin does not name one on github.com (nothing written)"} + } + // The live-session question, while it is open, is asked again + // here, of a person at a terminal (spc-2610141709430146 step 6). + if v, answered, aerr := ahoy.AskLiveSessions(root, newPrompter(cmd)); aerr == nil && answered { + fmt.Fprintf(cmd.ErrOrStderr(), "%srecorded sessions.live: %s in .abcd/config.json\n", prefix, v) + } + clientID, err := coresession.ReadAppClientID(root) + if err != nil { + return &exitError{Code: 2, Msg: prefix + err.Error() + " (nothing written)"} + } + fetch, err := newSessionReleaseFetcher() + if err != nil { + return fmt.Errorf("%s%w", prefix, err) + } + fmt.Fprintf(cmd.ErrOrStderr(), "%sreading release %s's checksums.txt from %s\n", prefix, termsafe.Sanitize(release), sessionReleaseOrigin) + res, err := coresession.WriteBlueprint(coresession.BlueprintRequest{Root: root, Release: release, Running: core.Version, Fetch: fetch, Repos: []string{served}, RelayOrigin: relayOrigin, Origins: pageOrigins, ClientID: clientID}) + if err != nil { + var ref *coresession.BlueprintRefusal + if errors.As(err, &ref) || res.Digest == "" { + return &exitError{Code: 2, Msg: prefix + err.Error()} + } + return fmt.Errorf("%s%w", prefix, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + for _, f := range res.Files { + fmt.Fprintf(w, "abcd session blueprint: %s %s\n", f.State, f.Path) + } + fmt.Fprintf(w, " pinned: abcd %s, %s sha256 %s\n", termsafe.Sanitize(res.Release), res.Asset, res.Digest) + fmt.Fprintf(w, " serves: %s (%s in render.yaml)\n", termsafe.Sanitize(strings.Join(res.Repos, ", ")), sessionReposEnv) + fmt.Fprintf(w, " admits pages from: %s (%s in render.yaml)\n", termsafe.Sanitize(strings.Join(res.Origins, ", ")), sessionOriginsEnv) + fmt.Fprintf(w, " from: %s\n", termsafe.Sanitize(res.Source)) + if res.Callback != "" { + fmt.Fprintf(w, " callback: list %s as a Callback URL on the team's GitHub App (its settings page, under Identifying and authorizing users), with wildcard matching off\n", termsafe.Sanitize(res.Callback)) + } + for _, line := range blueprintNext(res) { + fmt.Fprintf(w, " %s\n", line) + } + }) + }, + } + c.Flags().StringArrayVar(&pageOrigins, "origin", nil, "a session page origin the relay admits, as https://host[:port], followed by the page's base path when it is served below one, as https://host/base/ (repeatable; default "+coresession.DefaultPageOrigin+")") + c.Flags().StringVar(&relayOrigin, "relay-origin", "", "the deployed relay's https origin, as https://host[:port]; given, .abcd/relay/relays.json names it as the relay this repository trusts") + c.Flags().StringVar(&release, "release", "", "the abcd release tag to pin, as vMAJOR.MINOR.PATCH (default: this abcd's own version, when it is a release)") + return c +} + +func newSessionPageCommand(asJSON *bool) *cobra.Command { + var out, basePath string + c := &cobra.Command{ + Use: "page", + Long: "Write the brief session's page into --out: index.html and an identical 404.html,\n" + + "session.js, session.css, and the vendored editor bundle with its licences under\n" + + "vendor/. The files are plain and static, embedded in this binary; serve them\n" + + "from any static host, such as GitHub Pages, whose 404.html fallback is how\n" + + "///brief/ reaches the page. The page connects only to the relay the\n" + + "person names and to api.github.com with their own token, and it sends the\n" + + "person to github.com for sign-in and to open the pre-filled issues a session\n" + + "sends. It reads nothing from this checkout.\n\n" + + "--base-path names the path the site is served under, such as a project's\n" + + "GitHub Pages address owner.github.io//: the page then loads its files from\n" + + "under it and opens at ///brief/. It is plain path\n" + + "segments, and the root by default.\n\n" + + "It writes those six files and nothing else, and removes nothing. --out may be\n" + + "absent, empty, or a directory an earlier run wrote, whose files are replaced; a\n" + + "directory holding anything else, a symlink or a file is refused.\n\n" + + "Exit 2 on a refusal, with nothing written.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + const prefix = "abcd session page: " + res, err := web.Write(out, basePath) + if err != nil { + var ref *web.Refusal + if errors.As(err, &ref) { + return &exitError{Code: 2, Msg: prefix + ref.Error() + " (nothing written)"} + } + return fmt.Errorf("%s%w", prefix, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + verb := "wrote" + if res.Replaced { + verb = "replaced" + } + fmt.Fprintf(w, "abcd session page: %s %d files (%d bytes) in %s\n", verb, len(res.Files), res.Bytes, termsafe.Sanitize(res.Out)) + for _, f := range res.Files { + fmt.Fprintf(w, " %s\n", f) + } + fmt.Fprintf(w, " next: serve the directory from a static host; the page opens at %s//brief/\n", res.BasePath) + }) + }, + } + c.Flags().StringVar(&out, "out", "", "the directory to write the page into: absent, empty, or one an earlier run wrote") + c.Flags().StringVar(&basePath, "base-path", "/", "the path the site is served under, as plain path segments") + return c +} + +// sessionSetupHint is the way on for a project whose sign-in is not set up. +// While the live brief session question is unanswered, a person at a +// terminal is asked it here, as repository setup asks it +// (spc-2610141709430146 step 6), and the hint follows the answer. +func sessionSetupHint(cmd *cobra.Command, root string) string { + if v, answered, err := ahoy.AskLiveSessions(root, newPrompter(cmd)); err == nil && answered { + if v == ahoy.LiveSessionsNone { + return "live sessions were declined for this project; abcd session setup still sets them up by hand" + } + return "recorded that sessions may run " + sessionWhereWords(v) + "; run abcd session setup to create the team's sign-in app" + } + if v, set, err := ahoy.LiveSessions(root); err == nil && !set { + return "live sessions are not set up yet: answer the question abcd ahoy install asks at a terminal, or run abcd session setup" + } else if err == nil && v == ahoy.LiveSessionsNone { + return "live sessions were declined for this project; abcd session setup still sets them up by hand" + } + return "run abcd session setup to create the team's sign-in app" +} + +// sessionWhereWords says where sessions may run, in words. +func sessionWhereWords(where string) string { + switch where { + case coresession.WhereLocal: + return "on the host's computer" + case coresession.WhereCloud: + return "on the team's relay" + } + return "on the host's computer or the team's relay" +} + +// blueprintNext is what to do once the blueprint is written: Render's Deploy +// button where render.yaml sits at the root, else Render's New Blueprint +// form with its Blueprint Path. +func blueprintNext(res coresession.BlueprintResult) []string { + files := res.BlueprintPath + " and .abcd/relay/" + if res.DeployButton { + return []string{ + "next: commit and push " + files + ", then deploy the relay with Render's Deploy button:", + " " + res.DeployURL, + " Render's form asks for " + sessionSecretEnv + "; generate it on your GitHub App's settings page and paste it there", + } + } + return []string{ + "next: commit and push " + files + "; on Render choose New, then Blueprint, pick this repository and set the Blueprint Path to " + res.BlueprintPath, + " (the root render.yaml is your own, so Render's Deploy button would read that one)", + " Render's form asks for " + sessionSecretEnv + "; generate it on your GitHub App's settings page and paste it there", + } +} + +// The GitHub API session setup asks, and the browser it sends the person to. +// Package vars so a test answers for GitHub and plays the browser: no test +// reaches the network. +var ( + sessionGitHubAPI = "https://api.github.com" + sessionHTTPClient = &http.Client{Timeout: 30 * time.Second} + // sessionSetupVisit is told the setup page's address once it answers. + // abcd opens nothing itself: the person opens the address it prints. + sessionSetupVisit = func(string) {} +) + +// sessionAppWait is how long setup waits for the person to create the app: +// GitHub's manifest code lasts an hour. +const sessionAppWait = time.Hour + +// sessionSetupResult is what `session setup` did, and the steps left to the +// person, in order. +type sessionSetupResult struct { + Repo string `json:"repo"` + Where string `json:"where"` + Recorded string `json:"recorded,omitempty"` + ClientID string `json:"client_id"` + AppCreated bool `json:"app_created"` + AppSettings string `json:"app_settings,omitempty"` + AppInstall string `json:"app_install,omitempty"` + Blueprint *coresession.BlueprintResult `json:"blueprint,omitempty"` + RelayOrigin string `json:"relay_origin,omitempty"` + Callback string `json:"callback_url,omitempty"` + Steps []string `json:"steps"` +} + +func newSessionSetupCommand(asJSON *bool) *cobra.Command { + var where, relayOrigin, release string + c := &cobra.Command{ + Use: "setup", + Long: "Set up live brief sessions for this project, the step repository setup asks\n" + + "about (Set up live brief sessions? Where may sessions run?). It runs by hand at any\n" + + "time, a no at repository setup included. Where sessions may run comes from\n" + + "--where, else from .abcd/config.json (sessions.live), else, at a terminal, from\n" + + "the question; the answer is recorded there.\n\n" + + "It registers the team's own GitHub sign-in app through GitHub's App manifest\n" + + "flow: it prints an address on this computer whose page opens GitHub with the app\n" + + "filled in (Contents and Pull requests read and write, Metadata read, no\n" + + "webhook), and the person clicks Create GitHub App. It keeps only the app's public\n" + + "client id, in .abcd/relay/" + coresession.AppFile + "; the client secret, the webhook\n" + + "secret and the private key GitHub hands back are never written or shown. An app\n" + + "already recorded is kept, and setup says to remove the session page's own\n" + + "address from its callback URLs if it is listed, as an earlier setup had it.\n\n" + + "It then says what is left, with GitHub's own pages: installing the app on the\n" + + "repository; for local sessions, ticking Enable Device Flow on the app's settings\n" + + "page, which a manifest cannot do; for the cloud only, leaving it off. For the\n" + + "cloud it writes the relay's blueprint (as abcd session blueprint does) and gives\n" + + "Render's Deploy button, whose form asks for the client secret, which the person\n" + + "generates on the app's settings page and pastes straight in. --relay-origin\n" + + "records the deployed relay's address in .abcd/relay/relays.json and names the\n" + + "callback URL the app must list, the relay's origin followed by " + coresession.RelayCallbackPath + ";\n" + + "given before the app is created, the manifest sets it.\n\n" + + "It asks api.github.com whether the owner is an organisation, and converts the\n" + + "app GitHub created; for the cloud it reads the release's checksums. It waits up\n" + + "to an hour for the app to be created. Where sessions may run is recorded once\n" + + "the app is. Exit 2 on a refusal before then, with nothing written; a blueprint\n" + + "that cannot be written afterwards exits 2 with the app and the answer kept.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) (err error) { + defer endOnStop(&err) + const prefix = "abcd session setup: " + refuse := func(msg string) error { return &exitError{Code: 2, Msg: prefix + msg + " (nothing written)"} } + if where != "" && !coresession.ValidWhere(where) { + return refuse("--where takes local, cloud or both, not " + strconv.Quote(where)) + } + if relayOrigin != "" { + o, nerr := coresession.NormalizeRelayOrigin(relayOrigin) + if nerr != nil { + return refuse("--relay-origin " + nerr.Error()) + } + relayOrigin = o + } + if release != "" && !coresession.ValidReleaseTag(release) { + return refuse(strconv.Quote(release) + " is not a release tag; name one as vMAJOR.MINOR.PATCH") + } + cwd, err := os.Getwd() + if err != nil { + return err + } + root, err := gitutil.CheckoutRoot(cwd, "the live session's checkout") + if err != nil { + return refuse(err.Error()) + } + if !ahoy.Managed(root) { + return refuse("this checkout is not one abcd manages; run `abcd ahoy install` first") + } + repo, err := sessionOriginRepo(root) + if err != nil || !coresession.ValidRepo(repo) { + return refuse("live sessions serve this checkout's repository, and its origin does not name one on github.com") + } + stored, set, err := ahoy.LiveSessions(root) + if err != nil { + return refuse(err.Error()) + } + chosen := where + if chosen == "" && set && stored != ahoy.LiveSessionsNone { + chosen = stored + } + if chosen == "" { + v, ok := ahoy.AskSessionsWhere(newPrompter(cmd)) + if !ok { + return refuse("say where sessions may run with --where local|cloud|both, or run it at a terminal and answer the question") + } + chosen = v + } + if relayOrigin != "" && !coresession.AllowsCloud(chosen) { + return refuse("--relay-origin is the team relay's address, and sessions here run on the host's computer only") + } + if coresession.AllowsCloud(chosen) && release == "" { + release = core.Version + if !coresession.ValidReleaseTag(release) { + return refuse("this abcd (" + release + ") is not a release, and the relay's blueprint pins one; name it with --release vMAJOR.MINOR.PATCH") + } + } + clientID, err := coresession.ReadAppClientID(root) + if err != nil { + return refuse(err.Error()) + } + // For the cloud, the release's checksums are read before GitHub is + // asked for anything, so nothing after the app's creation can + // fail on a release that cannot be pinned. + var fetch *onceFetch + if coresession.AllowsCloud(chosen) { + inner, ferr := newSessionReleaseFetcher() + if ferr != nil { + return fmt.Errorf("%s%w", prefix, ferr) + } + fmt.Fprintf(cmd.ErrOrStderr(), "%sreading release %s's checksums.txt from %s\n", prefix, termsafe.Sanitize(release), sessionReleaseOrigin) + fetch = &onceFetch{inner: inner} + _, src, found, ferr := fetch.FetchReleaseAsset(release, "checksums.txt") + switch { + case ferr != nil: + return refuse("reading release " + release + "'s checksums: " + ferr.Error()) + case !found: + return refuse("release " + release + " publishes no checksums at " + src + ", so the relay cannot be pinned to it") + } + } + res := sessionSetupResult{Repo: repo, Where: chosen, ClientID: clientID} + stderr := cmd.ErrOrStderr() + var app coresession.CreatedApp + if clientID == "" { + owner, _, _ := strings.Cut(repo, "/") + org, oerr := session.OwnerIsOrganisation(cmd.Context(), sessionHTTPClient, sessionGitHubAPI, owner) + if oerr != nil { + return refuse("could not ask GitHub whether " + owner + " is an organisation, which decides where its app is registered: " + oerr.Error()) + } + manifest, merr := coresession.NewAppManifest(repo, chosen, "", relayOrigin) + if merr != nil { + return refuse(merr.Error()) + } + ln, lerr := session.LoopbackListener() + if lerr != nil { + return refuse("cannot listen on this computer for GitHub's answer: " + lerr.Error()) + } + flow := &session.AppSetup{ + Manifest: manifest, + FormURL: func(state string) string { return coresession.ManifestFormURL(owner, org, state) }, + API: sessionGitHubAPI, + Client: sessionHTTPClient, + } + ctx, cancel := context.WithTimeout(cmd.Context(), sessionAppWait) + defer cancel() + app, err = flow.Run(ctx, ln, func(page string) { + fmt.Fprintf(stderr, "%sopen this address in your browser to create the team's sign-in app:\n", prefix) + fmt.Fprintf(stderr, " %s\n", page) + fmt.Fprintln(stderr, " It opens GitHub with the app filled in for "+repo+": check its name, then click Create GitHub App.") + fmt.Fprintln(stderr, " Waiting for GitHub, for up to an hour; Ctrl-C stops without creating anything here.") + sessionSetupVisit(page) + }) + if err != nil { + return &exitError{Code: 1, Msg: prefix + "the sign-in app was not recorded: " + err.Error()} + } + if err := coresession.WriteAppFile(root, app.ClientID); err != nil { + return &exitError{Code: 1, Msg: prefix + "GitHub created the app, but its client id could not be written: " + err.Error() + + "; write {\"client_id\": \"" + app.ClientID + "\"} into .abcd/relay/" + coresession.AppFile} + } + res.ClientID, res.AppCreated = app.ClientID, true + res.AppSettings, res.AppInstall = app.SettingsURL(), app.InstallURL() + } + // Where sessions may run is recorded once the app is, so every + // refusal before this point leaves the checkout as it was. + if !set || stored != chosen { + if _, serr := ahoy.StoreLiveSessions(root, chosen); serr == nil { + from := "not set" + if set { + from = stored + } + res.Recorded = "sessions.live: " + from + " -> " + chosen + } else { + fmt.Fprintf(stderr, "%swhere sessions may run was not recorded: %s\n", prefix, termsafe.Sanitize(serr.Error())) + } + } + settings := res.AppSettings + if settings == "" { + settings = "the app's settings page on GitHub (Settings, then Developer settings, then GitHub Apps, then Edit beside the app)" + } + if res.AppCreated { + res.Steps = append(res.Steps, "Install the app on "+repo+": open "+res.AppInstall+", choose Only select repositories, pick "+repo+", and click Install.") + } else { + res.Steps = append(res.Steps, "The team's sign-in app is already recorded (client id "+res.ClientID+"); check on GitHub that it is installed on "+repo+".", + // An earlier setup had the app list the session page's own + // address as its callback; the sign-in now returns to the + // relay's own callback alone, so a listed page address is + // left over and comes off. + "If the app lists the session page's address, "+coresession.FormerPageCallbackURL(repo)+", as a Callback URL, remove it: on "+settings+ + ", under Identifying and authorizing users, delete that Callback URL and click Save changes. An earlier version of setup listed it; the sign-in no longer returns there.") + } + if coresession.AllowsLocal(chosen) { + res.Steps = append(res.Steps, "Switch on the app's device flow, which local sessions sign in with: open "+settings+ + ", tick Enable Device Flow under Identifying and authorizing users, and click Save changes. GitHub's manifest cannot tick it for you. "+ + "It is left off unless local sessions are allowed, because anyone holding the app's public client id can start a device sign-in.") + } else { + res.Steps = append(res.Steps, "Leave Enable Device Flow off on "+settings+ + ": only local sessions sign in with it, and anyone holding the app's public client id could start a device sign-in.") + } + if coresession.AllowsCloud(chosen) { + bp, berr := coresession.WriteBlueprint(coresession.BlueprintRequest{Root: root, Release: release, Running: core.Version, Fetch: fetch, + Repos: []string{repo}, RelayOrigin: relayOrigin, ClientID: res.ClientID}) + if berr != nil { + return &exitError{Code: 2, Msg: prefix + "the relay's blueprint was not written: " + berr.Error()} + } + res.Blueprint, res.RelayOrigin = &bp, relayOrigin + if relayOrigin != "" { + // The cloud's sign-in returns to the relay's own callback, + // which the app must list: GitHub sends a sign-in only to a + // callback the app lists. A manifest sets it for an app + // created now; one recorded before may lack it, and either + // may have wildcard matching on. + res.Callback = coresession.RelayCallbackURL(relayOrigin) + res.Steps = append(res.Steps, "Recorded the relay's address in .abcd/relay/"+coresession.RelaysFile+": commit and push it, and the session page finds the relay itself.", + "Make sure the team's sign-in returns to the relay: on "+settings+", under Identifying and authorizing users, list "+res.Callback+ + " as a Callback URL, with wildcard matching off, and click Save changes. GitHub sends a sign-in only to a callback the app lists, which ties every sign-in to this relay.") + } else { + if bp.DeployButton { + res.Steps = append(res.Steps, "Commit and push "+bp.BlueprintPath+" and .abcd/relay/, then deploy the relay with Render's Deploy button: "+bp.DeployURL+ + " (a private repository needs Render's own GitHub App installed on it first).") + } else { + res.Steps = append(res.Steps, "Commit and push "+bp.BlueprintPath+" and .abcd/relay/, then on Render choose New, then Blueprint, pick this repository and set the Blueprint Path to "+ + bp.BlueprintPath+"; the root render.yaml is your own, so Render's Deploy button would read that one.") + } + res.Steps = append(res.Steps, "Render's form asks for "+sessionSecretEnv+": on "+settings+ + ", under Client secrets, click Generate a new client secret and paste it straight into that field. abcd never sees it, and GitHub shows it only once.", + "Once Render shows the relay's address, record it: abcd session setup --relay-origin https://.onrender.com", + "Then list the relay's callback, https://.onrender.com"+coresession.RelayCallbackPath+", as a Callback URL on "+settings+ + ", under Identifying and authorizing users, with wildcard matching off; setup with --relay-origin names it exactly. Until it is listed, GitHub sign-in on the relay does not work.") + } + } + if coresession.AllowsLocal(chosen) { + res.Steps = append(res.Steps, "Start a session on your own computer with abcd session start.") + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + fmt.Fprintf(w, "abcd session setup: live brief sessions for %s may run %s\n", termsafe.Sanitize(repo), sessionWhereWords(chosen)) + if res.Recorded != "" { + fmt.Fprintf(w, " recorded %s in .abcd/config.json\n", termsafe.Sanitize(res.Recorded)) + } + if res.AppCreated { + fmt.Fprintf(w, " created the team's sign-in app; kept only its public client id, %s, in .abcd/relay/%s\n", termsafe.Sanitize(res.ClientID), coresession.AppFile) + } + if res.Blueprint != nil { + for _, f := range res.Blueprint.Files { + fmt.Fprintf(w, " %s %s\n", f.State, f.Path) + } + } + fmt.Fprintln(w, " what is left, in order:") + for i, step := range res.Steps { + fmt.Fprintf(w, " %d. %s\n", i+1, termsafe.Sanitize(step)) + } + }) + }, + } + c.Flags().StringVar(&where, "where", "", "where sessions may run: local, cloud or both (default: the answer recorded in .abcd/config.json)") + c.Flags().StringVar(&relayOrigin, "relay-origin", "", "the deployed relay's https origin, once Render shows it, as https://host[:port]; recorded in .abcd/relay/relays.json") + c.Flags().StringVar(&release, "release", "", "the abcd release the cloud relay pins, as vMAJOR.MINOR.PATCH (default: this abcd's own version, when it is a release)") + return c +} + +// onceFetch reads each release asset once: session setup checks the +// release's checksums before creating the app, and the blueprint pins the +// same bytes afterwards. +type onceFetch struct { + inner coresession.AssetFetcher + got map[string]onceAsset +} + +type onceAsset struct { + data []byte + url string + found bool + err error +} + +func (f *onceFetch) FetchReleaseAsset(tag, name string) ([]byte, string, bool, error) { + key := tag + "/" + name + if a, ok := f.got[key]; ok { + return a.data, a.url, a.found, a.err + } + data, url, found, err := f.inner.FetchReleaseAsset(tag, name) + if f.got == nil { + f.got = map[string]onceAsset{} + } + f.got[key] = onceAsset{data, url, found, err} + return data, url, found, err +} diff --git a/internal/surface/cli/session_setup_test.go b/internal/surface/cli/session_setup_test.go new file mode 100644 index 000000000..76461c4f9 --- /dev/null +++ b/internal/surface/cli/session_setup_test.go @@ -0,0 +1,375 @@ +package cli + +import ( + "encoding/json" + "html" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" +) + +// `abcd session setup` (spc-2610141709430146 step 6): the team's own GitHub +// App from a pre-filled manifest, the device-flow tick for local sessions, +// Render's Deploy button for the cloud, and the relay's address recorded. + +// fakeManifestAPI answers as GitHub's API does: the owner lookup and the +// manifest conversion, whose answer carries secrets. +func fakeManifestAPI(t *testing.T) (*[]string, func()) { + t.Helper() + var mu sync.Mutex + var asked []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + asked = append(asked, r.Method+" "+r.URL.Path) + mu.Unlock() + switch { + case r.URL.Path == "/users/acme": + io.WriteString(w, `{"login": "acme", "type": "Organization"}`) + case r.Method == http.MethodPost && r.URL.Path == "/app-manifests/code123/conversions": + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"id": 7, "slug": "acme-brief", "client_id": "Iv23liFromGitHub", "client_secret": "SECRET-ONE", + "webhook_secret": "SECRET-TWO", "pem": "SECRET-THREE", "owner": {"login": "acme", "type": "Organization"}}`) + default: + http.NotFound(w, r) + } + })) + oldAPI, oldVisit := sessionGitHubAPI, sessionSetupVisit + sessionGitHubAPI = srv.URL + // The browser: it opens the page, and GitHub, once the person clicks + // Create, sends it back with a code and the page's state. + sessionSetupVisit = func(page string) { + go func() { + resp, err := http.Get(page) + if err != nil { + t.Errorf("open %s: %v", page, err) + return + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + m := regexp.MustCompile(`action="([^"]+)"`).FindStringSubmatch(string(body)) + if m == nil { + t.Errorf("no form on the page:\n%s", body) + return + } + action, _ := url.Parse(html.UnescapeString(m[1])) + back, err := http.Get(strings.TrimSuffix(page, "/") + "/created?code=code123&state=" + url.QueryEscape(action.Query().Get("state"))) + if err != nil { + t.Errorf("redirect back: %v", err) + return + } + back.Body.Close() + }() + } + return &asked, func() { + sessionGitHubAPI, sessionSetupVisit = oldAPI, oldVisit + srv.Close() + } +} + +// sessionConfig gives the managed checkout the config its answer is kept in. +func sessionConfig(t *testing.T, repo, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSessionSetupLocalCreatesTheAppAndSaysToTickDeviceFlow(t *testing.T) { + repo := managedSessionRepo(t) + sessionConfig(t, repo, `{"meta": {"schema_version": 1}}`) + asked, done := fakeManifestAPI(t) + defer done() + out, err := runCLIStdinErr(t, "", "session", "setup", "--where", "local") + if err != nil { + t.Fatalf("setup: %v\n%s", err, out) + } + text := string(out) + if strings.Contains(text, "SECRET-") { + t.Fatalf("a secret reached the output:\n%s", text) + } + id, err := os.ReadFile(filepath.Join(repo, ".abcd", "relay", "github-app.json")) + if err != nil || strings.Contains(string(id), "SECRET") || !strings.Contains(string(id), `"client_id": "Iv23liFromGitHub"`) { + t.Fatalf("github-app.json (%v):\n%s", err, id) + } + for _, want := range []string{ + "http://127.0.0.1:", + "https://github.com/organizations/acme/settings/apps/acme-brief", + "Enable Device Flow", + "https://github.com/apps/acme-brief/installations/new", + "abcd session start", + } { + if !strings.Contains(text, want) { + t.Errorf("output does not say %q:\n%s", want, text) + } + } + if strings.Contains(text, "render.com") { + t.Errorf("a local-only setup offers Render:\n%s", text) + } + if _, err := os.Stat(filepath.Join(repo, "render.yaml")); err == nil { + t.Error("a local-only setup wrote a Blueprint") + } + cfg, _ := os.ReadFile(filepath.Join(repo, ".abcd", "config.json")) + if !strings.Contains(string(cfg), `"live": "local"`) { + t.Errorf("the answer is not recorded:\n%s", cfg) + } + if len(*asked) != 2 { + t.Errorf("asked GitHub %v", *asked) + } + + // Run again: the app is recorded, so nothing is created twice. + *asked = nil + out, err = runCLIStdinErr(t, "", "session", "setup") + if err != nil { + t.Fatalf("again: %v\n%s", err, out) + } + if len(*asked) != 0 || !strings.Contains(string(out), "already recorded") { + t.Errorf("a second run asked GitHub %v:\n%s", *asked, out) + } +} + +func TestSessionSetupCloudOffersTheDeployButton(t *testing.T) { + repo := managedSessionRepo(t) + sessionConfig(t, repo, `{"meta": {"schema_version": 1}, "sessions": {"live": "cloud"}}`) + swapSessionRelease(t) + _, done := fakeManifestAPI(t) + defer done() + out, err := runCLIStdinErr(t, "", "session", "setup", "--release", "v0.17.1") + if err != nil { + t.Fatalf("setup: %v\n%s", err, out) + } + text := string(out) + for _, want := range []string{ + "https://render.com/deploy?repo=https://github.com/acme/brief", + "Generate a new client secret", + "ABCD_SESSION_GITHUB_CLIENT_SECRET", + "Leave Enable Device Flow off", + "--relay-origin", + "https://github.com/apps/acme-brief/installations/new", + } { + if !strings.Contains(text, want) { + t.Errorf("output does not say %q:\n%s", want, text) + } + } + yaml, err := os.ReadFile(filepath.Join(repo, "render.yaml")) + if err != nil || !strings.Contains(string(yaml), `value: "Iv23liFromGitHub"`) || strings.Contains(string(yaml), "SECRET-") { + t.Fatalf("render.yaml (%v):\n%s", err, yaml) + } + + out, err = runCLIStdinErr(t, "", "session", "setup", "--release", "v0.17.1", "--relay-origin", "https://abcd-relay.onrender.com") + if err != nil { + t.Fatalf("--relay-origin: %v\n%s", err, out) + } + relays, err := os.ReadFile(filepath.Join(repo, ".abcd", "relay", "relays.json")) + if err != nil || !strings.Contains(string(relays), "https://abcd-relay.onrender.com") { + t.Fatalf("relays.json (%v):\n%s", err, relays) + } + // The relay's address known, setup names its callback exactly. + if !strings.Contains(string(out), "https://abcd-relay.onrender.com/auth/callback") { + t.Errorf("--relay-origin: output does not name the relay's callback:\n%s", out) + } +} + +func TestSessionSetupRefusals(t *testing.T) { + repo := managedSessionRepo(t) + sessionConfig(t, repo, `{"meta": {"schema_version": 1}}`) + _, done := fakeManifestAPI(t) + defer done() + if _, err := runCLIStdinErr(t, "", "session", "setup"); exitCode(err) != 2 || !strings.Contains(err.Error(), "--where") { + t.Errorf("nothing says where: %v, want exit 2 naming --where", err) + } + if _, err := runCLIStdinErr(t, "", "session", "setup", "--where", "moon"); exitCode(err) != 2 { + t.Errorf("an unknown where: %v", err) + } + if _, err := runCLIStdinErr(t, "", "session", "setup", "--where", "local", "--relay-origin", "https://x.example.org"); exitCode(err) != 2 { + t.Errorf("a relay address for local sessions: %v", err) + } + if _, err := os.Stat(filepath.Join(repo, ".abcd", "relay")); err == nil { + t.Error("a refusal wrote .abcd/relay/") + } + // No refusal changed the recorded answer. + cfg, _ := os.ReadFile(filepath.Join(repo, ".abcd", "config.json")) + var m map[string]any + if json.Unmarshal(cfg, &m) != nil || m["sessions"] != nil { + t.Errorf("a refusal recorded an answer:\n%s", cfg) + } + _ = time.Second +} + +// TestSessionSetupChecksTheReleaseBeforeCreatingTheApp: for the cloud, the +// release's checksums are read before GitHub is asked for anything, so a +// release that cannot be pinned refuses with no app created on GitHub and +// nothing written. +func TestSessionSetupChecksTheReleaseBeforeCreatingTheApp(t *testing.T) { + repo := managedSessionRepo(t) + sessionConfig(t, repo, `{"meta": {"schema_version": 1}, "sessions": {"live": "both"}}`) + swapSessionRelease(t) + asked, done := fakeManifestAPI(t) + defer done() + _, err := runCLIStdinErr(t, "", "session", "setup", "--release", "v9.9.9") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "checksums") { + t.Fatalf("a release with no checksums: %v, want exit 2 naming them", err) + } + if len(*asked) != 0 { + t.Errorf("GitHub was asked %v before the release was checked", *asked) + } + if _, err := os.Stat(filepath.Join(repo, ".abcd", "relay")); err == nil { + t.Error("a refusal wrote .abcd/relay/") + } +} + +// TestSessionSetupWithARecordedAppNamesTheCallbackForTheCloud: the cloud's +// web sign-in returns to the relay's own callback, which the team's app must +// list. With the relay's address, setup names the exact callback to add on +// the app's settings page; before it, it says the callback is the relay's +// address with /auth/callback, to add once Render shows it. The session +// page's own address is never the callback. +func TestSessionSetupWithARecordedAppNamesTheCallbackForTheCloud(t *testing.T) { + repo := managedSessionRepo(t) + sessionConfig(t, repo, `{"meta": {"schema_version": 1}}`) + if err := os.MkdirAll(filepath.Join(repo, ".abcd", "relay"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "relay", "github-app.json"), []byte(`{"client_id": "Iv23liRecorded"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + swapSessionRelease(t) + asked, done := fakeManifestAPI(t) + defer done() + const callback = "https://brief-relay.onrender.com/auth/callback" + const page = "https://abcdesign.app/acme/brief/brief/" + for _, where := range []string{"cloud", "both"} { + out, err := runCLIStdinErr(t, "", "session", "setup", "--where", where, "--release", "v0.17.1", "--relay-origin", "https://brief-relay.onrender.com") + if err != nil { + t.Fatalf("%s: %v\n%s", where, err, out) + } + text := string(out) + if !strings.Contains(text, "already recorded") || !strings.Contains(text, callback) || !strings.Contains(text, "Callback URL") { + t.Errorf("%s: output does not name the callback URL %s to add:\n%s", where, callback, text) + } + if !strings.Contains(text, "wildcard") { + t.Errorf("%s: output does not say to leave wildcard matching off:\n%s", where, text) + } + } + // Before the relay is deployed, the callback is named by its shape. + if err := os.Remove(filepath.Join(repo, ".abcd", "relay", "relays.json")); err != nil { + t.Fatal(err) + } + out, err := runCLIStdinErr(t, "", "session", "setup", "--where", "cloud", "--release", "v0.17.1") + if err != nil { + t.Fatalf("cloud, no relay yet: %v\n%s", err, out) + } + if text := string(out); !strings.Contains(text, "https://.onrender.com/auth/callback") { + t.Errorf("cloud, no relay yet: output does not say the callback is the relay's:\n%s", text) + } + out, err = runCLIStdinErr(t, "", "session", "setup", "--where", "local") + if err != nil { + t.Fatalf("local: %v\n%s", err, out) + } + if strings.Contains(string(out), "/auth/callback") { + t.Errorf("a local-only setup names a callback URL:\n%s", out) + } + if len(*asked) != 0 { + t.Errorf("GitHub was asked %v with the app already recorded", *asked) + } +} + +// TestSessionSetupWithARecordedAppSaysToRemoveThePageCallback: an earlier +// setup told people to list the session page's own address as the app's +// callback URL. The sign-in now returns to the relay's own callback, so an +// app recorded before is told to remove the page's address if it is listed, +// wherever sessions may run; the page's address is named only in that step, +// never as one to add. An app created now never listed it, and is told +// nothing about it. +func TestSessionSetupWithARecordedAppSaysToRemoveThePageCallback(t *testing.T) { + repo := managedSessionRepo(t) + sessionConfig(t, repo, `{"meta": {"schema_version": 1}}`) + swapSessionRelease(t) + asked, done := fakeManifestAPI(t) + defer done() + const page = "https://abcdesign.app/acme/brief/brief/" + steps := func(args ...string) []string { + t.Helper() + out, err := runCLIStdinErr(t, "", append([]string{"--json", "session", "setup"}, args...)...) + if err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + var res struct { + Steps []string `json:"steps"` + } + // The setup page's address is printed before the result, while the + // app is being created. + body := string(out) + if i := strings.Index(body, "\n{"); i >= 0 && !strings.HasPrefix(body, "{") { + body = body[i+1:] + } + if err := json.Unmarshal([]byte(body), &res); err != nil { + t.Fatalf("%v: not JSON: %v\n%s", args, err, out) + } + return res.Steps + } + naming := func(steps []string) []string { + var out []string + for _, s := range steps { + if strings.Contains(s, page) { + out = append(out, s) + } + } + return out + } + // Created now: the manifest never listed the page, so nothing is said. + if got := naming(steps("--where", "local")); len(got) != 0 { + t.Errorf("an app created now is told about the page's address: %q", got) + } + if len(*asked) == 0 { + t.Fatal("the app was not created") + } + *asked = nil + for _, args := range [][]string{ + {"--where", "local"}, + {"--where", "cloud", "--release", "v0.17.1"}, + {"--where", "both", "--release", "v0.17.1", "--relay-origin", "https://brief-relay.onrender.com"}, + } { + got := naming(steps(args...)) + if len(got) != 1 || !strings.Contains(got[0], "remove") || !strings.Contains(got[0], "Callback URL") || strings.Contains(got[0], "add ") { + t.Errorf("%v: steps naming the page's address = %q, want one saying to remove it", args, got) + } + } + if len(*asked) != 0 { + t.Errorf("GitHub was asked %v with the app already recorded", *asked) + } +} + +// TestSessionSetupRefusedAfterTheChoiceRecordsNothing: a refusal says +// "(nothing written)", so where sessions may run is recorded only once the +// app is: a refusal on the way leaves .abcd/config.json as it was. +func TestSessionSetupRefusedAfterTheChoiceRecordsNothing(t *testing.T) { + repo := managedSessionRepo(t) + sessionConfig(t, repo, `{"meta": {"schema_version": 1}}`) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + old := sessionGitHubAPI + sessionGitHubAPI = srv.URL + defer func() { sessionGitHubAPI = old }() + _, err := runCLIStdinErr(t, "", "session", "setup", "--where", "local") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "nothing written") { + t.Fatalf("GitHub unavailable: %v, want exit 2 saying nothing was written", err) + } + cfg, _ := os.ReadFile(filepath.Join(repo, ".abcd", "config.json")) + var m map[string]any + if json.Unmarshal(cfg, &m) != nil || m["sessions"] != nil { + t.Errorf("a refusal saying nothing was written recorded an answer:\n%s", cfg) + } +} diff --git a/internal/surface/cli/session_surface_test.go b/internal/surface/cli/session_surface_test.go new file mode 100644 index 000000000..0835904ff --- /dev/null +++ b/internal/surface/cli/session_surface_test.go @@ -0,0 +1,892 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + coresession "github.com/intentdriven/abcd/internal/core/session" + "github.com/intentdriven/abcd/internal/gittest" +) + +// sessionDigest is the checksum the fake release publishes. +const sessionDigest = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + +// fakeSessionRelease serves a release's checksums.txt from memory: no test +// reaches the network. +type fakeSessionRelease struct{ asked []string } + +func (f *fakeSessionRelease) FetchReleaseAsset(tag, name string) ([]byte, string, bool, error) { + f.asked = append(f.asked, tag+"/"+name) + url := "https://github.com/intentdriven/abcd/releases/download/" + tag + "/" + name + if tag == "v9.9.9" { + return nil, url, false, nil + } + return []byte(sessionDigest + " abcd-linux-amd64\n"), url, true, nil +} + +func swapSessionRelease(t *testing.T) *fakeSessionRelease { + t.Helper() + f := &fakeSessionRelease{} + old := newSessionReleaseFetcher + newSessionReleaseFetcher = func() (coresession.AssetFetcher, error) { return f, nil } + t.Cleanup(func() { newSessionReleaseFetcher = old }) + return f +} + +// managedSessionRepo is a checkout abcd manages, as the working directory. +func managedSessionRepo(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + repo := filepath.Join(home, "repo") + gitInitAt(t, repo) + gitRemoteAt(t, repo, "https://github.com/acme/brief.git") + if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("\n\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + return repo +} + +// gitRemoteAt names url as dir's origin remote. +func gitRemoteAt(t *testing.T, dir, url string) { + t.Helper() + cmd := exec.Command("git", "-C", dir, "remote", "add", "origin", url) + cmd.Env = gittest.Env(t) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v (%s)", err, out) + } +} + +func TestSessionBlueprintWritesThePinnedFiles(t *testing.T) { + repo := managedSessionRepo(t) + rel := swapSessionRelease(t) + out := string(runCLI(t, "session", "blueprint", "--release", "v0.17.1")) + for _, want := range []string{".abcd/relay/Dockerfile", "written render.yaml", "v0.17.1", sessionDigest, "checksums.txt", "https://render.com/deploy?repo=https://github.com/acme/brief"} { + if !strings.Contains(out, want) { + t.Errorf("output does not say %q:\n%s", want, out) + } + } + if strings.Contains(strings.ToLower(out), "funnel") { + t.Errorf("output mentions Funnel:\n%s", out) + } + docker, err := os.ReadFile(filepath.Join(repo, ".abcd", "relay", "Dockerfile")) + if err != nil || !bytes.Contains(docker, []byte(sessionDigest+" /usr/local/bin/abcd")) { + t.Fatalf("Dockerfile: %v\n%s", err, docker) + } + if strings.Join(rel.asked, ",") != "v0.17.1/checksums.txt" { + t.Fatalf("fetched %v", rel.asked) + } + + // --json puts the result alone on stdout; the fetch is named on stderr. + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"session", "blueprint", "--release", "v0.17.1", "--json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var res coresession.BlueprintResult + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("%v\n%s", err, stdout.String()) + } + if !strings.Contains(stderr.String(), "checksums.txt") { + t.Errorf("stderr does not name the fetch: %q", stderr.String()) + } + if res.Digest != sessionDigest || len(res.Files) != 2 || res.Files[0].State != "unchanged" { + t.Fatalf("--json = %+v", res) + } +} + +func TestSessionBlueprintRefusals(t *testing.T) { + repo := managedSessionRepo(t) + swapSessionRelease(t) + + // This test binary is no release, so the release must be named. + out, err := runCLIStdinErr(t, "", "session", "blueprint") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "--release") { + t.Fatalf("no --release on a dev build: %v\n%s", err, out) + } + // A release older than the first to carry `abcd session` is refused by + // name: its container would exit at once. + if _, err := runCLIStdinErr(t, "", "session", "blueprint", "--release", "v0.14.1"); exitCode(err) != 2 || !strings.Contains(err.Error(), "abcd session") { + t.Fatalf("a release without the session verb: %v, want exit 2 naming abcd session", err) + } + for _, args := range [][]string{ + {"session", "blueprint", "--release", "latest"}, + {"session", "blueprint", "--release", "v9.9.9"}, + } { + if _, err := runCLIStdinErr(t, "", args...); exitCode(err) != 2 { + t.Errorf("%v: %v, want exit 2", args, err) + } + } + if _, err := os.Stat(filepath.Join(repo, ".abcd")); err == nil { + t.Fatal("a refusal created .abcd/") + } + + runCLI(t, "session", "blueprint", "--release", "v0.17.1") + yaml := filepath.Join(repo, "render.yaml") + edited, err := os.ReadFile(yaml) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(yaml, bytes.Replace(edited, []byte("plan: free"), []byte("plan: starter"), 1), 0o644); err != nil { + t.Fatal(err) + } + _, err = runCLIStdinErr(t, "", "session", "blueprint", "--release", "v0.17.2") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "render.yaml") { + t.Fatalf("an edited render.yaml: %v, want exit 2 naming it", err) + } + + // Outside a checkout abcd manages. + plain := t.TempDir() + gitInitAt(t, plain) + t.Chdir(plain) + if _, err := runCLIStdinErr(t, "", "session", "blueprint", "--release", "v0.17.1"); exitCode(err) != 2 { + t.Fatalf("an unmanaged checkout: %v, want exit 2", err) + } +} + +// syncBuffer is a writer a running server and a test read at once. +type syncBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +// startServe runs `abcd session serve` with args until the test ends, and +// returns its output so far once it is listening. +func startServe(t *testing.T, args ...string) (*syncBuffer, func() error) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + root := NewRootCommand() + out := &syncBuffer{} + root.SetOut(out) + root.SetErr(out) + root.SetArgs(append([]string{"session", "serve"}, args...)) + done := make(chan error, 1) + go func() { done <- root.ExecuteContext(ctx) }() + stop := func() error { + cancel() + select { + case err := <-done: + return err + case <-time.After(10 * time.Second): + return errors.New("serve did not stop") + } + } + t.Cleanup(func() { _ = stop() }) + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(out.String(), "http://") { + select { + case err := <-done: + t.Fatalf("serve ended: %v\n%s", err, out.String()) + default: + } + if time.Now().After(deadline) { + t.Fatalf("serve never said where it listens:\n%s", out.String()) + } + time.Sleep(10 * time.Millisecond) + } + return out, stop +} + +func TestSessionServeListensOnLoopbackAndStops(t *testing.T) { + t.Setenv("ABCD_SESSION_GITHUB_CLIENT_SECRET", "test-secret-value-never-printed") + out, stop := startServe(t, "--listen", "127.0.0.1:0", "--client-id", "Iv1.publicid", "--json") + var started struct { + URL string `json:"url"` + Loopback bool `json:"loopback"` + SignIn bool `json:"sign_in"` + Hint string `json:"hint"` + } + if err := json.NewDecoder(strings.NewReader(out.String())).Decode(&started); err != nil { + t.Fatalf("the start line is not JSON: %v\n%s", err, out.String()) + } + if !started.Loopback || !started.SignIn || !strings.HasPrefix(started.URL, "http://127.0.0.1:") { + t.Fatalf("start = %+v", started) + } + if !strings.Contains(started.Hint, "tailscale serve") || strings.Contains(strings.ToLower(started.Hint), "funnel") { + t.Fatalf("hint = %q, want Serve and never Funnel", started.Hint) + } + resp, err := http.Get(started.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz = %d", resp.StatusCode) + } + if err := stop(); err != nil { + t.Fatalf("stop: %v", err) + } + if strings.Contains(out.String(), "test-secret-value-never-printed") { + t.Fatalf("the secret reached the output:\n%s", out.String()) + } + if _, err := http.Get(started.URL + "/healthz"); err == nil { + t.Fatal("the relay still answers after it stopped") + } +} + +// TestSessionServeOnLoopbackCountsTailnetVisitorsApart: serve on loopback +// is the host's own machine behind Tailscale Serve, so the wrong codes of one +// tailnet address shut out that address and not every visitor Serve proxies. +func TestSessionServeOnLoopbackCountsTailnetVisitorsApart(t *testing.T) { + out, _ := startServe(t, "--listen", "127.0.0.1:0", "--json") + var started struct { + URL string `json:"url"` + } + if err := json.NewDecoder(strings.NewReader(out.String())).Decode(&started); err != nil { + t.Fatalf("the start line is not JSON: %v\n%s", err, out.String()) + } + wsURL := "ws" + strings.TrimPrefix(started.URL, "http") + "/session" + // join tries a wrong code once on ws and returns the error code the + // relay answers; dial connects as Serve proxies the visitor at addr. + join := func(ws *websocket.Conn) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := ws.Write(ctx, websocket.MessageText, []byte(`{"type":"join","code":"ZZZZZZ"}`)); err != nil { + t.Fatalf("write: %v", err) + } + _, data, err := ws.Read(ctx) + if err != nil { + t.Fatalf("read: %v", err) + } + var m map[string]any + _ = json.Unmarshal(data, &m) + code, _ := m["code"].(string) + return code + } + dial := func(addr string) *websocket.Conn { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ws, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{ + "Origin": {"https://abcdesign.app"}, "X-Forwarded-For": {addr}, + }}) + if err != nil { + t.Fatalf("dial as %s: %v", addr, err) + } + t.Cleanup(func() { _ = ws.CloseNow() }) + return ws + } + mal := dial("100.101.102.103") // abcd-lint:allow: a tailnet address, the shape under test + for i := 0; i < coresession.New(coresession.Options{}).Limits().MaxFailedJoins; i++ { + if got := join(mal); got != "bad-code" { + t.Fatalf("wrong code %d: %q, want bad-code", i, got) + } + } + if got := join(dial("100.101.102.103")); got != "too-many-tries" { // abcd-lint:allow: a tailnet address, the shape under test + t.Fatalf("the address past its limit: %q, want too-many-tries", got) + } + if got := join(dial("100.64.0.9")); got != "bad-code" { // abcd-lint:allow: a tailnet address, the shape under test + t.Fatalf("another tailnet address: %q, want bad-code, not held up by the first", got) + } +} + +func TestSessionServeTextNamesTheTailnetRoute(t *testing.T) { + t.Setenv("ABCD_SESSION_GITHUB_CLIENT_SECRET", "") + out, stop := startServe(t, "--listen", "127.0.0.1:0") + _ = stop() + text := out.String() + for _, want := range []string{"loopback", "tailscale serve --bg", "sign-in is not set up", "serves sessions for"} { + if !strings.Contains(text, want) { + t.Errorf("output does not say %q:\n%s", want, text) + } + } + if strings.Contains(strings.ToLower(text), "funnel") { + t.Errorf("output mentions Funnel:\n%s", text) + } +} + +// TestSessionServeWithoutASecretOffersNoSignIn: the device flow belongs to +// a local session alone (abcd session start). serve with a client id and no +// secret says sign-in is off, and its /config offers none. +func TestSessionServeWithoutASecretOffersNoSignIn(t *testing.T) { + t.Setenv("ABCD_SESSION_GITHUB_CLIENT_SECRET", "") + out, stop := startServe(t, "--listen", "127.0.0.1:0", "--client-id", "Iv1.publicid", "--json") + var started struct { + URL string `json:"url"` + SignIn bool `json:"sign_in"` + } + if err := json.NewDecoder(strings.NewReader(out.String())).Decode(&started); err != nil { + t.Fatalf("the start line is not JSON: %v\n%s", err, out.String()) + } + if started.SignIn { + t.Errorf("serve with no secret says sign-in is on: %s", out.String()) + } + resp, err := http.Get(started.URL + "/config") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(body), `"sign_in":false`) || strings.Contains(string(body), `"device"`) { + t.Errorf("/config = %s, want sign-in off and no device flow", body) + } + _ = stop() + + out, stop = startServe(t, "--listen", "127.0.0.1:0", "--client-id", "Iv1.publicid") + _ = stop() + if text := out.String(); !strings.Contains(text, "sign-in is not set up") || strings.Contains(text, "device flow") { + t.Errorf("serve with no secret:\n%s\nwant sign-in not set up, and no device flow", text) + } +} + +func TestSessionServeRefusals(t *testing.T) { + for _, args := range [][]string{ + {"session", "serve", "--listen", "nonsense"}, + {"session", "serve", "--listen", "example.com:80"}, + {"session", "serve", "--listen", "127.0.0.1:99999"}, + {"session", "serve", "--origin", "*"}, + {"session", "serve", "extra"}, + } { + if _, err := runCLIStdinErr(t, "", args...); exitCode(err) != 2 { + t.Errorf("%v: %v, want exit 2", args, err) + } + } +} + +func TestSessionPageWritesThePageAndRefusesAForeignDirectory(t *testing.T) { + out := filepath.Join(t.TempDir(), "page") + text := string(runCLI(t, "session", "page", "--out", out)) + for _, want := range []string{"wrote 6 files", "index.html", "404.html", "vendor/collab.js", "///brief/"} { + if !strings.Contains(text, want) { + t.Errorf("output does not say %q:\n%s", want, text) + } + } + var res struct { + Out string `json:"out"` + Files []string `json:"files"` + Replaced bool `json:"replaced"` + } + if err := json.Unmarshal(runCLI(t, "session", "page", "--out", out, "--json"), &res); err != nil { + t.Fatal(err) + } + if !res.Replaced || len(res.Files) != 6 || res.Out != out { + t.Fatalf("--json = %+v", res) + } + + foreign := t.TempDir() + if err := os.WriteFile(filepath.Join(foreign, "notes.md"), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := runCLIStdinErr(t, "", "session", "page", "--out", foreign) + if exitCode(err) != 2 || !strings.Contains(err.Error(), "notes.md") || !strings.Contains(err.Error(), "nothing written") { + t.Fatalf("a foreign --out: %v, want exit 2 naming notes.md", err) + } + if entries, _ := os.ReadDir(foreign); len(entries) != 1 { + t.Fatalf("a refusal wrote into the directory: %v", entries) + } + if _, err := runCLIStdinErr(t, "", "session", "page"); exitCode(err) != 2 { + t.Fatalf("no --out: %v, want exit 2", err) + } +} + +func TestSessionPageWritesUnderABasePath(t *testing.T) { + out := filepath.Join(t.TempDir(), "page") + var res struct { + BasePath string `json:"base_path"` + } + if err := json.Unmarshal(runCLI(t, "session", "page", "--out", out, "--base-path", "abcdesign", "--json"), &res); err != nil { + t.Fatal(err) + } + if res.BasePath != "/abcdesign/" { + t.Fatalf("base_path = %q, want /abcdesign/", res.BasePath) + } + html, err := os.ReadFile(filepath.Join(out, "index.html")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(html), `src="/abcdesign/session.js"`) { + t.Fatalf("index.html does not load /abcdesign/session.js:\n%s", html) + } + text := string(runCLI(t, "session", "page", "--out", out, "--base-path", "/abcdesign/")) + if !strings.Contains(text, "/abcdesign///brief/") { + t.Errorf("the next step does not name the page under its base path:\n%s", text) + } + if text := string(runCLI(t, "session", "page", "--out", out)); !strings.Contains(text, "opens at ///brief/") { + t.Errorf("the default next step changed:\n%s", text) + } + fresh := filepath.Join(t.TempDir(), "page") + _, err = runCLIStdinErr(t, "", "session", "page", "--out", fresh, "--base-path", "../up") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "plain path segments") { + t.Fatalf("a bad --base-path: %v, want exit 2", err) + } + if _, err := os.Stat(fresh); !os.IsNotExist(err) { + t.Fatal("a refused --base-path created the directory") + } +} + +func TestSessionPageHelpSaysWhatGitHubIsReachedFor(t *testing.T) { + text := string(runCLI(t, "session", "page", "--help")) + flat := strings.Join(strings.Fields(text), " ") + if !strings.Contains(flat, "to github.com for sign-in and to open the pre-filled issues a session sends") { + t.Errorf("the help does not say the page opens pre-filled issues on github.com:\n%s", text) + } + if !strings.Contains(flat, "--base-path") { + t.Errorf("the help does not name --base-path:\n%s", text) + } +} + +// TestSessionBlueprintServesTheOriginRepository: the blueprint names the +// checkout's origin repository as the one the relay serves, as a plain +// environment value; without a GitHub origin it refuses and writes nothing. +func TestSessionBlueprintServesTheOriginRepository(t *testing.T) { + repo := managedSessionRepo(t) + swapSessionRelease(t) + out := string(runCLI(t, "session", "blueprint", "--release", "v0.17.1")) + if !strings.Contains(out, "acme/brief") { + t.Errorf("output does not name the served repository:\n%s", out) + } + yaml, err := os.ReadFile(filepath.Join(repo, "render.yaml")) + if err != nil || !bytes.Contains(yaml, []byte("- key: ABCD_SESSION_REPOS\n value: \"acme/brief\"")) { + t.Fatalf("render.yaml: %v\n%s", err, yaml) + } + + bare := managedSessionRepoWithoutOrigin(t) + _, err = runCLIStdinErr(t, "", "session", "blueprint", "--release", "v0.17.1") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "origin") { + t.Fatalf("no origin: %v, want exit 2 naming the origin", err) + } + if _, err := os.Stat(filepath.Join(bare, ".abcd", "relay")); err == nil { + t.Fatal("a refusal wrote the blueprint") + } +} + +// managedSessionRepoWithoutOrigin is a managed checkout with no remote, as +// the working directory. +func managedSessionRepoWithoutOrigin(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "bare") + gitInitAt(t, repo) + if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("\n\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + return repo +} + +// TestSessionServeBeyondLoopbackNeedsItsRepositories: a relay listening +// beyond this computer refuses to start without the repositories it serves, +// and refuses a malformed one. 192.0.2.1 is a documentation address no test +// machine holds, so a run that gets past the checks fails to listen instead +// of listening. +func TestSessionServeBeyondLoopbackNeedsItsRepositories(t *testing.T) { + t.Setenv("ABCD_SESSION_REPOS", "") + _, err := runCLIStdinErr(t, "", "session", "serve", "--listen", "192.0.2.1:0") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "--repo") || !strings.Contains(err.Error(), "nothing started") { + t.Fatalf("no repositories: %v, want exit 2 naming --repo", err) + } + for _, args := range [][]string{ + {"--repo", "acme/brief"}, + {"--owner", "acme"}, + } { + _, err := runCLIStdinErr(t, "", append([]string{"session", "serve", "--listen", "192.0.2.1:0"}, args...)...) + if exitCode(err) != 2 || !strings.Contains(err.Error(), "cannot listen") { + t.Errorf("%v: %v, want past the check to the listen", args, err) + } + } + t.Setenv("ABCD_SESSION_REPOS", "acme/brief") + _, err = runCLIStdinErr(t, "", "session", "serve", "--listen", "192.0.2.1:0") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "cannot listen") { + t.Errorf("ABCD_SESSION_REPOS: %v, want past the check to the listen", err) + } + for _, c := range []struct { + env string + args []string + }{ + {"", []string{"--repo", "acme"}}, + {"", []string{"--repo", "acme/brief/x"}}, + {"", []string{"--owner", "acme/brief"}}, + {"acme/brief,a/b/c", nil}, + {"acme/brief,,", nil}, + } { + t.Setenv("ABCD_SESSION_REPOS", c.env) + _, err := runCLIStdinErr(t, "", append([]string{"session", "serve", "--listen", "127.0.0.1:0"}, c.args...)...) + if exitCode(err) != 2 { + t.Errorf("env %q args %v: %v, want exit 2", c.env, c.args, err) + } + } +} + +// TestSessionServeOnLoopbackServesTheOriginRepository: on loopback with no +// list, the relay serves the checkout's origin repository, or every +// repository outside a checkout; a list given is the list served. +func TestSessionServeOnLoopbackServesTheOriginRepository(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + gitInitAt(t, repo) + gitRemoteAt(t, repo, "git@github.com:acme/brief.git") + if r, o, err := sessionServed(true, nil, nil, "", repo); err != nil || strings.Join(r, ",") != "acme/brief" || len(o) != 0 { + t.Fatalf("in a checkout: %v %v %v, want acme/brief", r, o, err) + } + if r, o, err := sessionServed(true, nil, nil, "", t.TempDir()); err != nil || len(r)+len(o) != 0 { + t.Fatalf("outside a checkout: %v %v %v, want every repository", r, o, err) + } + if _, _, err := sessionServed(false, nil, nil, "", repo); err == nil { + t.Fatal("beyond loopback, the origin stood in for a list") + } + r, o, err := sessionServed(false, []string{"acme/notes"}, nil, " acme/brief , team ", repo) + if err != nil || strings.Join(r, ",") != "acme/notes,acme/brief" || strings.Join(o, ",") != "team" { + t.Fatalf("with a list: %v %v %v", r, o, err) + } + + // The start line says what it serves. + t.Setenv("ABCD_SESSION_REPOS", "") + t.Chdir(repo) + out, stop := startServe(t, "--listen", "127.0.0.1:0", "--json") + _ = stop() + var s struct { + Repos []string `json:"repos"` + Any bool `json:"any_repository"` + } + if err := json.NewDecoder(strings.NewReader(out.String())).Decode(&s); err != nil { + t.Fatalf("the start line is not JSON: %v\n%s", err, out.String()) + } + if strings.Join(s.Repos, ",") != "acme/brief" || s.Any { + t.Fatalf("start = %+v, want acme/brief", s) + } +} + +// TestSessionBlueprintWritesTheRelayOrigin: --relay-origin adds relays.json, +// and a malformed one is refused with nothing written. +func TestSessionBlueprintWritesTheRelayOrigin(t *testing.T) { + repo := managedSessionRepo(t) + swapSessionRelease(t) + _, err := runCLIStdinErr(t, "", "session", "blueprint", "--release", "v0.17.1", "--relay-origin", "http://relay.example.org") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "--relay-origin") { + t.Fatalf("an http relay origin: %v, want exit 2", err) + } + if _, err := os.Stat(filepath.Join(repo, ".abcd")); err == nil { + t.Fatal("a refusal wrote .abcd/") + } + out := string(runCLI(t, "session", "blueprint", "--release", "v0.17.1", "--relay-origin", "https://relay.example.org")) + if !strings.Contains(out, ".abcd/relay/relays.json") { + t.Errorf("output does not name relays.json:\n%s", out) + } + // The exact callback the team's app must list for the relay's sign-in. + if !strings.Contains(out, "https://relay.example.org/auth/callback") || !strings.Contains(out, "Callback URL") { + t.Errorf("output does not name the relay's callback to add:\n%s", out) + } + data, err := os.ReadFile(filepath.Join(repo, ".abcd", "relay", "relays.json")) + if err != nil || !strings.Contains(string(data), `"https://relay.example.org"`) { + t.Fatalf("relays.json: %v\n%s", err, data) + } +} + +// TestSessionServeSignsInThroughAGitHubAppOnly: the relay no longer takes an +// OAuth app; a deployment still naming one is refused rather than started +// with a sign-in it cannot honour, and the old variable set to github-app is +// harmless (security review 2, item 3). +func TestSessionServeSignsInThroughAGitHubAppOnly(t *testing.T) { + t.Setenv("ABCD_SESSION_REPOS", "") + for _, v := range []string{"oauth-app", "personal-token"} { + t.Setenv("ABCD_SESSION_GITHUB_APP_TYPE", v) + _, err := runCLIStdinErr(t, "", "session", "serve", "--listen", "127.0.0.1:0") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "ABCD_SESSION_GITHUB_APP_TYPE") || !strings.Contains(err.Error(), "GitHub App") { + t.Fatalf("ABCD_SESSION_GITHUB_APP_TYPE=%s: %v, want exit 2 naming the variable and a GitHub App", v, err) + } + } + t.Setenv("ABCD_SESSION_GITHUB_APP_TYPE", "github-app") + t.Setenv("ABCD_SESSION_GITHUB_CLIENT_SECRET", "test-secret-value-never-printed") + out, _ := startServe(t, "--listen", "127.0.0.1:0", "--client-id", "Iv1.publicid", "--json") + var started struct { + URL string `json:"url"` + } + if err := json.NewDecoder(strings.NewReader(out.String())).Decode(&started); err != nil { + t.Fatalf("the start line is not JSON: %v\n%s", err, out.String()) + } + resp, err := http.Get(started.URL + "/config") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var cfg map[string]any + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil || cfg["app_type"] != "github-app" { + t.Fatalf("/config = %v, %v; want github-app", cfg, err) + } +} + +// TestSessionServeReadsItsOriginsFromTheEnvironment: with no --origin, serve +// admits exactly the origins ABCD_SESSION_ORIGINS names, which the blueprint +// sets on the team's relay; --origin, given, wins; a malformed entry is +// refused (security review 2, item 4). +func TestSessionServeReadsItsOriginsFromTheEnvironment(t *testing.T) { + t.Setenv("ABCD_SESSION_REPOS", "") + allowed := func(base, o string) bool { + req, _ := http.NewRequest(http.MethodGet, base+"/config", nil) + req.Header.Set("Origin", o) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp.Header.Get("Access-Control-Allow-Origin") == o + } + urlOf := func(out *syncBuffer) string { + var started struct { + URL string `json:"url"` + } + if err := json.NewDecoder(strings.NewReader(out.String())).Decode(&started); err != nil { + t.Fatalf("the start line is not JSON: %v\n%s", err, out.String()) + } + return started.URL + } + t.Setenv("ABCD_SESSION_ORIGINS", "https://pages.example.org, https://other.example.org") + out, stop := startServe(t, "--listen", "127.0.0.1:0", "--json") + base := urlOf(out) + for o, want := range map[string]bool{ + "https://pages.example.org": true, "https://other.example.org": true, "https://abcdesign.app": false, "http://localhost:8000": false, + } { + if got := allowed(base, o); got != want { + t.Errorf("origin %s admitted=%v, want %v", o, got, want) + } + } + _ = stop() + out, stop = startServe(t, "--listen", "127.0.0.1:0", "--json", "--origin", "https://flag.example.org") + base = urlOf(out) + if !allowed(base, "https://flag.example.org") || allowed(base, "https://pages.example.org") { + t.Error("--origin did not replace ABCD_SESSION_ORIGINS") + } + _ = stop() + t.Setenv("ABCD_SESSION_ORIGINS", "https://pages.example.org/x/../y/") + _, err := runCLIStdinErr(t, "", "session", "serve", "--listen", "127.0.0.1:0") + if exitCode(err) != 2 || !strings.Contains(err.Error(), "ABCD_SESSION_ORIGINS") { + t.Fatalf("a malformed ABCD_SESSION_ORIGINS: %v, want exit 2 naming it", err) + } +} + +// TestSessionBlueprintNamesThePageOrigins: --origin names the page origins +// render.yaml admits, https://abcdesign.app by default. +func TestSessionBlueprintNamesThePageOrigins(t *testing.T) { + repo := managedSessionRepo(t) + swapSessionRelease(t) + runCLI(t, "session", "blueprint", "--release", "v0.17.1") + data, err := os.ReadFile(filepath.Join(repo, "render.yaml")) + if err != nil || !strings.Contains(string(data), "- key: ABCD_SESSION_ORIGINS\n value: \"https://abcdesign.app\"") { + t.Fatalf("render.yaml: %v\n%s", err, data) + } + runCLI(t, "session", "blueprint", "--release", "v0.17.1", "--origin", "https://pages.example.org", "--origin", "https://abcdesign.app") + data, _ = os.ReadFile(filepath.Join(repo, "render.yaml")) + if !strings.Contains(string(data), "value: \"https://pages.example.org,https://abcdesign.app\"") { + t.Fatalf("render.yaml does not name the given origins:\n%s", data) + } + if _, err := runCLIStdinErr(t, "", "session", "blueprint", "--release", "v0.17.1", "--origin", "http://pages.example.org"); exitCode(err) != 2 { + t.Fatalf("an http page origin: %v, want exit 2", err) + } + // A page served below a base path is named with it. + runCLI(t, "session", "blueprint", "--release", "v0.17.1", "--origin", "https://acme.github.io/brief") + data, _ = os.ReadFile(filepath.Join(repo, "render.yaml")) + if !strings.Contains(string(data), "value: \"https://acme.github.io/brief/\"") { + t.Fatalf("render.yaml does not name the page's base path:\n%s", data) + } + if _, err := runCLIStdinErr(t, "", "session", "blueprint", "--release", "v0.17.1", "--origin", "https://acme.github.io/x/../"); exitCode(err) != 2 { + t.Fatalf("a base path with a dot segment: %v, want exit 2", err) + } +} + +// swapTailnet answers for this computer's tailnet name. +func swapTailnet(t *testing.T, name string, err error) { + t.Helper() + old := sessionTailnetName + sessionTailnetName = func(context.Context, string) (string, error) { return name, err } + t.Cleanup(func() { sessionTailnetName = old }) +} + +// writeSessionApp writes the GitHub App's public client id where repository +// setup records it. +func writeSessionApp(t *testing.T, repo, body string) { + t.Helper() + dir := filepath.Join(repo, ".abcd", "relay") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "github-app.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// startSession runs `abcd session start` with args until the test ends, and +// returns its output once it has printed the link. +func startSession(t *testing.T, args ...string) (*syncBuffer, func() error) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + root := NewRootCommand() + out := &syncBuffer{} + root.SetOut(out) + root.SetErr(out) + root.SetArgs(append([]string{"session", "start"}, args...)) + done := make(chan error, 1) + go func() { done <- root.ExecuteContext(ctx) }() + stop := func() error { + cancel() + select { + case err := <-done: + return err + case <-time.After(10 * time.Second): + return errors.New("start did not stop") + } + } + t.Cleanup(func() { _ = stop() }) + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(out.String(), "https://") { + select { + case err := <-done: + t.Fatalf("start ended: %v\n%s", err, out.String()) + default: + } + if time.Now().After(deadline) { + t.Fatalf("start never printed its link:\n%s", out.String()) + } + time.Sleep(10 * time.Millisecond) + } + return out, stop +} + +// TestSessionStartServesTheBriefOnTheTailnet: `abcd session start` is the +// host's one command for a local session. It serves the checkout's +// repository, signs in by GitHub's device flow with the client id setup +// recorded, serves the page itself, answers only its tailnet name and +// loopback, and prints the Tailscale step and the link (adr-2610161400542916). +func TestSessionStartServesTheBriefOnTheTailnet(t *testing.T) { + repo := managedSessionRepo(t) + writeSessionApp(t, repo, `{"client_id": "Iv23liPublicId"}`) + swapTailnet(t, "host.example.ts.net", nil) + // A secret in the environment is never read: a local session holds none. + t.Setenv("ABCD_SESSION_GITHUB_CLIENT_SECRET", "test-secret-value-never-printed") + out, stop := startSession(t, "--port", "0", "--json") + var started struct { + URL string `json:"url"` + Link string `json:"link"` + Repo string `json:"repo"` + ServeStep string `json:"serve_step"` + SignIn string `json:"sign_in"` + } + if err := json.NewDecoder(strings.NewReader(out.String())).Decode(&started); err != nil { + t.Fatalf("the start line is not JSON: %v\n%s", err, out.String()) + } + port := started.URL[strings.LastIndex(started.URL, ":")+1:] + if !strings.HasPrefix(started.URL, "http://127.0.0.1:") || started.Link != "https://host.example.ts.net/acme/brief/brief/" || + started.Repo != "acme/brief" || started.ServeStep != "tailscale serve --bg "+port || started.SignIn != "device" { + t.Fatalf("start = %+v", started) + } + get := func(host, path string) (int, string) { + req, _ := http.NewRequest(http.MethodGet, started.URL+path, nil) + req.Host = host + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var b bytes.Buffer + _, _ = b.ReadFrom(resp.Body) + return resp.StatusCode, b.String() + } + if code, body := get("host.example.ts.net", "/acme/brief/brief/"); code != http.StatusOK || !strings.Contains(body, `name="abcd-session-relay" content="self"`) { + t.Fatalf("the brief page on the tailnet name: %d\n%s", code, body) + } + if code, _ := get("evil.example", "/acme/brief/brief/"); code != http.StatusMisdirectedRequest { + t.Fatalf("another host name: %d, want 421", code) + } + if code, body := get("127.0.0.1:"+port, "/config"); code != http.StatusOK || !strings.Contains(body, `"flow":"device"`) || !strings.Contains(body, "Iv23liPublicId") { + t.Fatalf("/config: %d %s", code, body) + } + wsURL := "ws" + strings.TrimPrefix(started.URL, "http") + "/session" + dial := func(origin string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ws, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {origin}}}) + if err == nil { + _ = ws.CloseNow() + } + return err + } + if err := dial("https://host.example.ts.net"); err != nil { + t.Fatalf("the tailnet page was refused: %v", err) + } + if err := dial("https://abcdesign.app"); err == nil { + t.Fatal("a local session admitted abcdesign.app's page") + } + if err := stop(); err != nil { + t.Fatalf("stop: %v", err) + } + if strings.Contains(out.String(), "test-secret-value-never-printed") { + t.Fatalf("the secret reached the output:\n%s", out.String()) + } +} + +// TestSessionStartSaysWhatToDoInPlainWords: the text names the one Tailscale +// step, the link, and how people outside the tailnet join, and never Funnel. +func TestSessionStartSaysWhatToDoInPlainWords(t *testing.T) { + repo := managedSessionRepo(t) + writeSessionApp(t, repo, `{"client_id": "Iv23liPublicId"}`) + swapTailnet(t, "host.example.ts.net", nil) + out, stop := startSession(t, "--port", "0") + _ = stop() + text := out.String() + for _, want := range []string{"acme/brief", "tailscale serve --bg ", "https://host.example.ts.net/acme/brief/brief/", "guests", "Ctrl-C"} { + if !strings.Contains(text, want) { + t.Errorf("output does not say %q:\n%s", want, text) + } + } + if strings.Contains(strings.ToLower(text), "funnel") { + t.Errorf("output mentions Funnel:\n%s", text) + } +} + +// TestSessionStartRefusals: no client id, no Tailscale, or no GitHub +// repository, and nothing starts. +func TestSessionStartRefusals(t *testing.T) { + repo := managedSessionRepo(t) + swapTailnet(t, "host.example.ts.net", nil) + t.Setenv("ABCD_SESSION_GITHUB_CLIENT_ID", "") + if _, err := runCLIStdinErr(t, "", "session", "start", "--port", "0"); exitCode(err) != 2 || !strings.Contains(err.Error(), "sign-in is not set up") { + t.Errorf("no client id: %v, want exit 2 saying sign-in is not set up", err) + } + writeSessionApp(t, repo, `{"client_id": "Iv23liPublicId", "client_secret": "x"}`) + if _, err := runCLIStdinErr(t, "", "session", "start", "--port", "0"); exitCode(err) != 2 || !strings.Contains(err.Error(), "client_secret") { + t.Errorf("a secret in the app file: %v, want exit 2", err) + } + writeSessionApp(t, repo, `{"client_id": "Iv23liPublicId"}`) + swapTailnet(t, "", errors.New("Tailscale is not running on this computer")) + if _, err := runCLIStdinErr(t, "", "session", "start", "--port", "0"); exitCode(err) != 2 || !strings.Contains(err.Error(), "Tailscale") { + t.Errorf("no Tailscale: %v, want exit 2 naming Tailscale", err) + } + swapTailnet(t, "host.example.ts.net", nil) + for _, args := range [][]string{ + {"session", "start", "--port", "99999"}, + {"session", "start", "--client-id", "has space"}, + {"session", "start", "extra"}, + } { + if _, err := runCLIStdinErr(t, "", args...); exitCode(err) != 2 { + t.Errorf("%v: %v, want exit 2", args, err) + } + } + t.Chdir(managedSessionRepoWithoutOrigin(t)) + if _, err := runCLIStdinErr(t, "", "session", "start", "--port", "0", "--client-id", "Iv23liPublicId"); exitCode(err) != 2 { + t.Errorf("a checkout without a GitHub origin: %v, want exit 2", err) + } +} diff --git a/internal/surface/cli/setup_interview_test.go b/internal/surface/cli/setup_interview_test.go index d03f7210f..90ac00835 100644 --- a/internal/surface/cli/setup_interview_test.go +++ b/internal/surface/cli/setup_interview_test.go @@ -182,7 +182,7 @@ func newSetupRun(t *testing.T) setupRun { } // The README question is answered already, for the drain rule's reason: // it too is put only to a person at a terminal (spc-2610031704338417). - if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), []byte(`{"readme": {"parts": []}}`+"\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), []byte(`{"readme": {"parts": []}, "sessions": {"live": "none"}}`+"\n"), 0o644); err != nil { t.Fatal(err) } return setupRun{home: home, repo: repo} diff --git a/internal/surface/cli/site.go b/internal/surface/cli/site.go index b45e31e50..b6ecdeb2e 100644 --- a/internal/surface/cli/site.go +++ b/internal/surface/cli/site.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/intentdriven/abcd/internal/core/implement/loop" + "github.com/intentdriven/abcd/internal/core/launch/scaffold" "github.com/intentdriven/abcd/internal/core/site" "github.com/intentdriven/abcd/internal/termsafe" ) @@ -47,8 +48,8 @@ func newSiteCommand(asJSON *bool) *cobra.Command { siteCmd.Flags().StringVar(&statusOut, "out", site.DefaultOutDir, "output directory to report on") var buildOut string - var version, commit, stampDate string - var preview bool + var version, commit, stampDate, buildBase string + var preview, buildProject bool buildCmd := &cobra.Command{ Use: "build", Args: cobra.NoArgs, @@ -63,7 +64,9 @@ func newSiteCommand(asJSON *bool) *cobra.Command { Stamp: site.BuildStamp{Version: version, Commit: commit, GeneratedAt: stampDate, Preview: preview}, // The Status page's Now / Next / Later block reads the // build's state file through the loop, as the bare board does. - Lanes: loop.StatusLanes, + Lanes: loop.StatusLanes, + BasePath: buildBase, + Project: buildProject, }) if err != nil { // A failure after the build completed ui.json still says what @@ -85,6 +88,8 @@ func newSiteCommand(asJSON *bool) *cobra.Command { buildCmd.Flags().StringVar(&commit, "commit", "", "commit for the footer and the build stamp (default: git HEAD)") buildCmd.Flags().StringVar(&stampDate, "date", "", "date for the build stamp (default: the newest release's date)") buildCmd.Flags().BoolVar(&preview, "preview", false, "stamp the build as unreleased at this commit, for a preview deployment of an untagged tree") + buildCmd.Flags().StringVar(&buildBase, "base-path", "/", basePathUsage) + buildCmd.Flags().BoolVar(&buildProject, "project", false, projectUsage("build")) buildCmd.MarkFlagsMutuallyExclusive("preview", "version") siteCmd.AddCommand(buildCmd) @@ -93,6 +98,16 @@ func newSiteCommand(asJSON *bool) *cobra.Command { return siteCmd } +// projectUsage describes --project on the two verbs that take it: `site +// build` builds a project site, `lint site` gates one. +func projectUsage(verb string) string { + return verb + " a project site, every page built from what abcd writes to the repository and the interface words, styles and scripts " + + "taken from the binary; .abcd/site.json then holds the site's settings only" +} + +// basePathUsage describes --base-path on the two verbs that take it. +const basePathUsage = "path the site is served below on its host, such as /widgets/ for a project's GitHub Pages address; every root-absolute link is placed under it" + // siteRootAndOut resolves what a site verb reads and where it writes. The site // is a fact about the repository, not about the directory the operator stands // in, so the root is the checkout's, as the other per-repository verbs resolve @@ -117,7 +132,8 @@ func siteRootAndOut(cmd *cobra.Command, out string) (string, string, error) { // over a built output directory, rendering it first when it holds no // index.html. It exits 1 when any gate fails, so a release job can stop on it. func newLintSiteCommand(asJSON *bool) *cobra.Command { - var checkOut string + var checkOut, checkBase string + var checkProject bool checkCmd := &cobra.Command{ Use: "site", Args: cobra.NoArgs, @@ -126,7 +142,7 @@ func newLintSiteCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - res, err := site.Check(site.CheckRequest{RepoRoot: root, OutDir: out, Lanes: loop.StatusLanes}) + res, err := site.Check(site.CheckRequest{RepoRoot: root, OutDir: out, Lanes: loop.StatusLanes, BasePath: checkBase, Project: checkProject}) if err != nil { return &exitError{Code: 2, Msg: "abcd lint site: " + scrubPaths(err)} } @@ -142,6 +158,8 @@ func newLintSiteCommand(asJSON *bool) *cobra.Command { }, } checkCmd.Flags().StringVar(&checkOut, "out", site.DefaultOutDir, "built output directory to check (rendered first if absent)") + checkCmd.Flags().StringVar(&checkBase, "base-path", "/", basePathUsage) + checkCmd.Flags().BoolVar(&checkProject, "project", false, projectUsage("gate")) return checkCmd } @@ -194,9 +212,15 @@ func renderSiteStatus(w io.Writer, st site.Status) { fmt.Fprintf(w, " this repo declares no site composition; nothing to build\n") return } - fmt.Fprintf(w, " chapters: %d\n", st.Chapters) + if st.Project { + fmt.Fprintf(w, " project site: built from what abcd writes; interface words from the binary\n") + } else { + fmt.Fprintf(w, " chapters: %d\n", st.Chapters) + } fmt.Fprintf(w, " issue ledger: %s\n", publishedWord(st.IssueLedge)) - fmt.Fprintf(w, " ui strings: %s\n", mark(st.UIStrings, termsafe.Sanitize(st.UIPath))) + if !st.Project { + fmt.Fprintf(w, " ui strings: %s\n", mark(st.UIStrings, termsafe.Sanitize(st.UIPath))) + } if st.Baseline { fmt.Fprintf(w, " baseline: %s (%d unresolved references admitted)\n", termsafe.Sanitize(st.BaselinePath), st.BaselineN) @@ -217,6 +241,10 @@ func renderSiteStatus(w io.Writer, st site.Status) { default: fmt.Fprintf(w, " output: %s (not built)\n", termsafe.Sanitize(st.OutDir)) } + if st.Project { + fmt.Fprintf(w, "run `abcd site build --project` to render\n") + return + } fmt.Fprintf(w, "run `abcd site build` to render\n") } @@ -275,10 +303,12 @@ func publishedWord(on bool) string { // writes the repository half, and — each only once confirmed (adr-44) — the // forge's deployment environments and, with a hosting credential on this // machine, the host. An unanswered run declines both remote writes, so a script -// that pipes nothing changes nothing remote; --yes says yes in advance. +// that pipes nothing changes nothing remote; --yes says yes in advance, except +// to publishing over a site the host already serves, which only a person at a +// terminal or --replace-existing answers. func newSiteSetupCommand(asJSON *bool) *cobra.Command { - var name, domain string - var confirm, yes bool + var name, domain, provider string + var confirm, yes, replaceExisting bool cmd := &cobra.Command{ Use: "setup", Args: cobra.NoArgs, @@ -299,7 +329,8 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { asker = alwaysConfirm{} } res, err := site.Setup(site.SetupRequest{ - RepoRoot: cwd, Name: name, Domain: domain, Confirm: confirm, Asker: asker, + RepoRoot: cwd, Name: name, Domain: domain, Provider: provider, Confirm: confirm, Asker: asker, + Unattended: yes, ReplaceExisting: replaceExisting, ConfirmTool: confirmTool, Context: cmd.Context(), }) if err != nil { @@ -322,8 +353,13 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "host name when the composition names none (default: the repository's name)") cmd.Flags().StringVar(&domain, "domain", "", "custom domain to route to the host when the composition names none") + cmd.Flags().StringVar(&provider, "provider", "", "hosting provider when the composition names none: "+strings.Join(site.Providers(), " or ")+ + " (default "+site.DefaultProvider+"); github-pages asks whether to switch the project site on") cmd.Flags().BoolVar(&confirm, "confirm", false, "replace a workflow or host configuration that differs from what setup writes") - cmd.Flags().BoolVar(&yes, "yes", false, "confirm the forge and host changes without being asked (never the install of a missing gh); without it an unanswered run declines them") + cmd.Flags().BoolVar(&yes, "yes", false, "confirm the forge and host changes without being asked (never the install of a missing gh, "+ + "nor publishing over a site already live, which takes --replace-existing); without it an unanswered run declines them") + cmd.Flags().BoolVar(&replaceExisting, "replace-existing", false, "publish over a site the host already serves for this repository without asking; "+ + "with --yes and no --replace-existing, setup refuses when such a site exists") return cmd } @@ -354,7 +390,10 @@ func renderSiteSetup(w io.Writer, res site.SetupResult) { fmt.Fprintf(w, " repository\n") for _, f := range res.Files { line := fmt.Sprintf(" %-*s %s", width, f.Status, f.Path) - if f.Detail != "" && f.Status != "kept" { + // A kept seed file's reason is the same for every seed, so it is left + // unsaid; any other kept file (a workflow edited by hand, or whose + // removal was declined) says why it was kept. + if f.Detail != "" && !(f.Status == scaffold.StatusKept && f.Detail == scaffold.SeedKeptDetail) { line += " (" + f.Detail + ")" } fmt.Fprintln(w, termsafe.Sanitize(line)) diff --git a/internal/surface/cli/site_basepath_test.go b/internal/surface/cli/site_basepath_test.go new file mode 100644 index 000000000..1e617905a --- /dev/null +++ b/internal/surface/cli/site_basepath_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestSiteVerbsTakeABasePath: `site build` and `lint site` both take the path +// the site is served below (itd-2610091010348892), and both refuse one that is +// not plain path segments before reading anything else. +func TestSiteVerbsTakeABasePath(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t) + t.Chdir(repo.Root()) + for _, verb := range [][]string{{"site", "build"}, {"lint", "site"}} { + out := filepath.Join(t.TempDir(), "site") + args := append(append([]string{}, verb...), "--out", out, "--base-path", "/../elsewhere/") + got, err := runCLIErr(t, args...) + if code := exitCodeOf(err); code != 2 || !strings.Contains(err.Error(), "base path") { + t.Fatalf("`abcd %s --base-path /../elsewhere/` = exit %d, %v:\n%s", strings.Join(verb, " "), code, err, got) + } + args = append(append([]string{}, verb...), "--out", out, "--base-path", "/widgets/") + _, err = runCLIErr(t, args...) + if err == nil || strings.Contains(err.Error(), "base path") || strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("`abcd %s --base-path /widgets/` was refused for its base path: %v", strings.Join(verb, " "), err) + } + } +} + +// TestSiteVerbsTakeProject: `site build` and `lint site` both name the project +// route with --project (itd-2610091010348892), and both refuse a project +// site's settings without it: the route is declared, never inferred. +func TestSiteVerbsTakeProject(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t) + repo.Write(".abcd/site.json", `{"schema_version": 1, "mode": "project", "purpose": "x", `+ + `"hosting": {"provider": "github-pages", "name": "widgets", "publish": true}}`+"\n") + repo.Write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n## [0.1.0] - 2026-01-02\n\n- The first cut.\n") + repo.Commit("a project site's settings") + // A GitHub origin names the site; with no identity block either, the + // gate refuses a site with no title. + repo.Git("remote", "add", "origin", "https://github.com/widgets-owner/widgets.git") + t.Chdir(repo.Root()) + for _, verb := range [][]string{{"site", "build"}, {"lint", "site"}} { + out := filepath.Join(t.TempDir(), "site") + args := append(append([]string{}, verb...), "--out", out) + got, err := runCLIErr(t, args...) + if code := exitCodeOf(err); code != 2 || !strings.Contains(err.Error(), "pass --project") { + t.Fatalf("`abcd %s` over project settings = exit %d, %v:\n%s", strings.Join(verb, " "), code, err, got) + } + got, err = runCLIErr(t, append(args, "--project")...) + if err != nil { + t.Fatalf("`abcd %s --project` = %v:\n%s", strings.Join(verb, " "), err, got) + } + } +} diff --git a/internal/surface/cli/site_setup_test.go b/internal/surface/cli/site_setup_test.go index a6dad0727..2f0225cf9 100644 --- a/internal/surface/cli/site_setup_test.go +++ b/internal/surface/cli/site_setup_test.go @@ -313,3 +313,75 @@ func TestSiteVerbsSayWhichLabelsTheyAdded(t *testing.T) { t.Fatalf("a failed build names the label it added before the error: stderr = %q, err = %v", errOut, err) } } + +// TestSiteSetupTakesTheGitHubPagesProvider: --provider reaches the verb. With +// no forge to say whether the repository is public, the github-pages provider +// refuses before it asks or writes anything, since it publishes public +// repositories only (itd-2610091010348892). +func TestSiteSetupTakesTheGitHubPagesProvider(t *testing.T) { + 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": []}`+"\n") + r.Write(".abcd/development/IDENTITY.md", "# Identity\n\n## Identity (canonical)\n\n- **Title:** Example\n- **Tagline:** An example.\n") + r.Write("docs/README.md", "# Example\n\nThe example's documentation.\n") + r.Commit("the example") + t.Chdir(r.Root()) + + out, err := runCLIErr(t, "site", "setup", "--provider", "github-pages", "--yes") + if code := exitCodeOf(err); code != 2 || !strings.Contains(err.Error(), "github-pages publishes public repositories only") { + t.Fatalf("exit %d, %v:\n%s", code, err, out) + } + if _, serr := os.Stat(filepath.Join(r.Root(), ".abcd", "site.json")); !os.IsNotExist(serr) { + t.Fatal("a refused run wrote the composition") + } + if out, err = runCLIErr(t, "site", "setup", "--provider", "no-such-host"); exitCodeOf(err) != 2 || !strings.Contains(err.Error(), "not a provider abcd ships") { + t.Fatalf("an unknown provider: %v\n%s", err, out) + } +} + +// TestSiteSetupTextSaysWhyAWorkflowWasKept: setup keeps a switched-off site's +// workflow when it was edited by hand or its removal was declined, and the text +// output says which. A seed file the repository already had is kept for the +// one reason every seed is, which the output does not repeat. +func TestSiteSetupTextSaysWhyAWorkflowWasKept(t *testing.T) { + for _, why := range []string{ + "differs from the workflow setup writes, so it was edited by hand and setup leaves it; remove it yourself if it is no longer wanted", + "its removal was declined", + "not a regular file, so setup leaves it", + } { + res := site.SetupResult{ + Status: site.StatusNoChange, + Files: []scaffold.FileOutcome{ + {Path: "site-src/site.css", Status: scaffold.StatusKept, Detail: scaffold.SeedKeptDetail}, + {Path: ".github/workflows/site.yml", Status: scaffold.StatusKept, Detail: why}, + }, + Host: site.HostOutcome{Provider: "github-pages", Name: "example-site", Status: site.HostNotReached}, + } + var buf bytes.Buffer + renderSiteSetup(&buf, res) + if !strings.Contains(buf.String(), ".github/workflows/site.yml ("+why+")") { + t.Errorf("the kept workflow's reason %q is not printed:\n%s", why, buf.String()) + } + if strings.Contains(buf.String(), scaffold.SeedKeptDetail) { + t.Errorf("a kept seed file repeats the reason every seed is kept:\n%s", buf.String()) + } + } +} + +// TestSiteSetupTakesReplaceExisting: --yes answers the forge and host changes +// but never the takeover of a live site; --replace-existing is the one way to +// answer that in advance. +func TestSiteSetupTakesReplaceExisting(t *testing.T) { + cmd := newSiteSetupCommand(new(bool)) + f := cmd.Flags().Lookup("replace-existing") + if f == nil || f.Value.Type() != "bool" { + t.Fatal("site setup has no --replace-existing switch") + } + if !strings.Contains(f.Usage, "already") { + t.Errorf("--replace-existing usage does not say it publishes over a site already there: %q", f.Usage) + } + if yes := cmd.Flags().Lookup("yes").Usage; !strings.Contains(yes, "--replace-existing") { + t.Errorf("--yes usage does not say it leaves the takeover to --replace-existing: %q", yes) + } +} diff --git a/internal/surface/cli/version.go b/internal/surface/cli/version.go index e8f071ef8..3b55835d2 100644 --- a/internal/surface/cli/version.go +++ b/internal/surface/cli/version.go @@ -62,6 +62,11 @@ type ahoyOutput struct { // Q1 alone, then the parts as tabs of at most four (spc-2610031704338417). // The plugin page passes each on unchanged. ReadmeTool []*hostQuestionCall `json:"readme_tool,omitempty"` + // SessionsTool is the live brief session questions, while they are open, + // as the host's question tool takes them: Q1 alone, then Q2 alone + // (spc-2610141709430146 step 6). The plugin page passes each on + // unchanged. + SessionsTool []*hostQuestionCall `json:"sessions_tool,omitempty"` } // checkResult is the explicit network check's outcome, named source and all. diff --git a/internal/surface/dashboard/listener_boundary_test.go b/internal/surface/dashboard/listener_boundary_test.go index b55cdc9a7..e2684e876 100644 --- a/internal/surface/dashboard/listener_boundary_test.go +++ b/internal/surface/dashboard/listener_boundary_test.go @@ -6,6 +6,7 @@ import ( "go/parser" "go/token" "io/fs" + "path" "path/filepath" "sort" "strconv" @@ -13,11 +14,13 @@ import ( "testing" ) -// The dashboard is the only listener abcd has (adr-2610032150581128). The -// scanner below holds the tree to it, in the shape of the home resolver's -// TestOnlyTheHomeResolverNamesTheHome: it parses every non-test Go file under -// internal/ and cmd/ (testdata skipped) and reports every call that opens a -// listener. Only this package may make one. +// abcd has two listeners, each admitted by adr-2610032150581128: the +// dashboard, and the brief session's relay, which the record's amendment of +// 2026-10-14 admits as the second. The scanner below holds the tree to them, +// in the shape of the home resolver's TestOnlyTheHomeResolverNamesTheHome: it +// parses every non-test Go file under internal/ and cmd/ (testdata skipped) +// and reports every call that opens a listener. Only the two allowed packages +// may make one. // listenerFuncs are the package functions that open a listener, by import // path: a call through any import name of the package is judged. @@ -35,8 +38,24 @@ var listenerFuncs = map[string][]string{ // are called on: (*net.ListenConfig).Listen, (*http.Server).ListenAndServe. var listenerMethods = []string{"Listen", "ListenPacket", "ListenAndServe", "ListenAndServeTLS", "ServeTLS"} -// listenerPackage is the one package allowed to open a listener. -const listenerPackage = "internal/surface/dashboard" +// listenerPackages are the packages allowed to open a listener, each with the +// record that admits it. +var listenerPackages = map[string]string{ + "internal/surface/dashboard": "adr-2610032150581128", + "internal/surface/session": "adr-2610032150581128, as amended 2026-10-14", +} + +// allowedListenerPackage reports the allowed package rel (a slash-separated +// file path) is a file of, if any: the package's own directory exactly, never +// a subpackage below it, which is a package of its own with no record +// admitting it. +func allowedListenerPackage(rel string) (string, bool) { + dir := path.Dir(rel) + if _, ok := listenerPackages[dir]; ok { + return dir, true + } + return "", false +} // listenerTestDoubles are non-test files outside the package that open a // listener for tests alone, each with its reason; the test also proves no @@ -131,11 +150,11 @@ func TestOnlyTheDashboardOpensAListener(t *testing.T) { t.Fatalf("the scan read %d files; it is not looking at the tree", len(files)) } var bad []string - judged := 0 + judged := map[string]int{} for rel, f := range files { calls := listenerCalls(fset, f) - if strings.HasPrefix(rel, listenerPackage+"/") { - judged += len(calls) + if pkg, ok := allowedListenerPackage(rel); ok { + judged[pkg] += len(calls) continue } if _, ok := listenerTestDoubles[rel]; ok { @@ -145,12 +164,14 @@ func TestOnlyTheDashboardOpensAListener(t *testing.T) { bad = append(bad, fmt.Sprintf("%s:%d: %s", rel, c.line, c.call)) } } - if judged == 0 { - t.Fatal("the scan found no listener in the dashboard package; it is not looking at the calls") + for pkg := range listenerPackages { + if judged[pkg] == 0 { + t.Fatalf("the scan found no listener in %s; it is not looking at the calls", pkg) + } } sort.Strings(bad) for _, b := range bad { - t.Errorf("%s opens a listener outside %s: the dashboard is the only listener abcd has (adr-2610032150581128)", b, listenerPackage) + t.Errorf("%s opens a listener outside the dashboard and the session relay: they are the only listeners abcd has (adr-2610032150581128 and its amendment of 2026-10-14)", b) } // A test double's package reaches the binary only through an import from // a non-test file; there must be none. @@ -199,3 +220,23 @@ func TestListenerScannerIsArmed(t *testing.T) { } } } + +// TestAllowedListenerPackagesAreExact: an allowed package is admitted as its +// own directory, never its subpackages, so a listener added under +// internal/surface/session/web would fail the gate (security review 2, +// item 11c). +func TestAllowedListenerPackagesAreExact(t *testing.T) { + for rel, want := range map[string]bool{ + "internal/surface/session/server.go": true, + "internal/surface/session/listen.go": true, + "internal/surface/dashboard/server.go": true, + "internal/surface/session/web/web.go": false, + "internal/surface/session/web/x/serve.go": false, + "internal/surface/dashboard/sub/listen.go": false, + "internal/surface/sessionx/listen.go": false, + } { + if _, got := allowedListenerPackage(rel); got != want { + t.Errorf("allowedListenerPackage(%q) = %v, want %v", rel, got, want) + } + } +} diff --git a/internal/surface/session/appsetup.go b/internal/surface/session/appsetup.go new file mode 100644 index 000000000..cb5ef75b7 --- /dev/null +++ b/internal/surface/session/appsetup.go @@ -0,0 +1,243 @@ +package session + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "net" + "net/http" + "net/url" + "regexp" + "sync" + "time" + + coresession "github.com/intentdriven/abcd/internal/core/session" +) + +// The loopback half of GitHub's App manifest flow, for repository setup +// (spc-2610141709430146 step 6). `abcd session setup` listens on this +// computer alone and prints one address. Its page posts the pre-filled +// manifest to GitHub, where the person clicks Create; GitHub sends the +// browser back with a code, which this side converts into the app +// (POST /app-manifests/{code}/conversions, unauthenticated, within the +// hour). Only the public fields are kept (coresession.ParseConversion): the +// conversion's secrets are read off the wire into nothing, never written, +// logged or shown. + +// maxConversion bounds GitHub's answer to the conversion. +const maxConversion = 1 << 20 + +// manifestCodeRe is a manifest code as it travels into GitHub's path. +var manifestCodeRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) + +// AppSetup is one run of the manifest flow. +type AppSetup struct { + // Manifest is the app to register; Run sets its redirect to itself. + Manifest coresession.AppManifest + // FormURL is GitHub's new-app page the form posts to, carrying state. + FormURL func(state string) string + // API is GitHub's REST origin. + API string + // Client asks GitHub. + Client *http.Client +} + +// submitScript posts the form on load, so the person's one click is GitHub's +// Create; the button stays for a browser that runs no script. The page's +// policy admits this script by its hash alone. +const submitScript = "document.getElementById('f').submit();" + +var submitHash = func() string { + sum := sha256.Sum256([]byte(submitScript)) + return "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" +}() + +var setupPage = template.Must(template.New("setup").Parse(` +Create the team's sign-in app + +

      Create the team's sign-in app

      +

      GitHub opens with the app filled in for {{.Repo}}. Check its name, then click Create GitHub App.

      +
      + + +
      + + +`)) + +var donePage = template.Must(template.New("done").Parse(` +{{.Title}} +

      {{.Title}}

      {{.Body}}

      +`)) + +// LoopbackListener opens the listener on this computer's loopback address +// that GitHub's answer to the app manifest returns to. It lives here, in a +// package the listener boundary admits (adr-2610032150581128, as amended +// 2026-10-14), so the setup verb opens no listener of its own. +func LoopbackListener() (net.Listener, error) { return net.Listen("tcp", "127.0.0.1:0") } + +// Run serves the flow on ln until GitHub's redirect brings a code that +// converts, ctx ends, or the conversion fails. opened is told the address to +// open, once the page answers. Every request must name ln's own address as +// its host, so a page on another name cannot drive it (the relay's +// DNS-rebinding guard, here too). +func (s *AppSetup) Run(ctx context.Context, ln net.Listener, opened func(string)) (coresession.CreatedApp, error) { + defer ln.Close() + stateBytes := make([]byte, 24) + if _, err := rand.Read(stateBytes); err != nil { + return coresession.CreatedApp{}, err + } + state := hex.EncodeToString(stateBytes) + host := ln.Addr().String() + base := "http://" + host + manifest := s.Manifest + manifest.RedirectURL = base + "/created" + rawManifest, err := json.Marshal(manifest) + if err != nil { + return coresession.CreatedApp{}, err + } + + type outcome struct { + app coresession.CreatedApp + err error + } + result := make(chan outcome, 1) + var once sync.Once + finish := func(o outcome) { once.Do(func() { result <- o }) } + + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) { + pageHeaders(w) + _ = setupPage.Execute(w, map[string]string{ + "Repo": manifest.URL, "Action": s.FormURL(state), "Manifest": string(rawManifest), + }) + }) + mux.HandleFunc("GET /created", func(w http.ResponseWriter, r *http.Request) { + pageHeaders(w) + q := r.URL.Query() + if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state)) != 1 || !manifestCodeRe.MatchString(q.Get("code")) { + w.WriteHeader(http.StatusBadRequest) + _ = donePage.Execute(w, map[string]string{"Title": "This link is not this setup's", + "Body": "Go back to your terminal and open the address abcd session setup printed."}) + return + } + app, err := s.convert(r.Context(), q.Get("code")) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _ = donePage.Execute(w, map[string]string{"Title": "The app could not be read back", + "Body": "GitHub did not answer as expected. Go back to your terminal; it says what to do next."}) + finish(outcome{err: err}) + return + } + _ = donePage.Execute(w, map[string]string{"Title": "The sign-in app is created", + "Body": "Go back to your terminal for the next steps: installing the app on the repository, and its settings."}) + finish(outcome{app: app}) + }) + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != host { + http.Error(w, "misdirected request", http.StatusMisdirectedRequest) + return + } + mux.ServeHTTP(w, r) + }), + ReadHeaderTimeout: 10 * time.Second, + ErrorLog: nil, + } + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + finish(outcome{err: err}) + } + }() + defer func() { + shut, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shut) + }() + opened(base + "/") + select { + case o := <-result: + return o.app, o.err + case <-ctx.Done(): + return coresession.CreatedApp{}, ctx.Err() + } +} + +// pageHeaders keeps the setup pages to themselves: no framing, no referrer, +// nothing loaded, the one script by its hash, and the form posting to GitHub +// alone. +func pageHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-store") + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", "default-src 'none'; script-src "+submitHash+"; form-action https://github.com; frame-ancestors 'none'; base-uri 'none'") +} + +// convert exchanges the manifest code for the app, keeping its public +// fields alone. +func (s *AppSetup) convert(ctx context.Context, code string) (coresession.CreatedApp, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.API+"/app-manifests/"+url.PathEscape(code)+"/conversions", nil) + if err != nil { + return coresession.CreatedApp{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + resp, err := s.Client.Do(req) + if err != nil { + return coresession.CreatedApp{}, fmt.Errorf("asking GitHub for the new app: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxConversion)) + if err != nil { + return coresession.CreatedApp{}, fmt.Errorf("reading GitHub's answer: %w", err) + } + if resp.StatusCode != http.StatusCreated { + return coresession.CreatedApp{}, fmt.Errorf("GitHub answered %d to the app's conversion; its code lasts an hour, so run abcd session setup again", resp.StatusCode) + } + return coresession.ParseConversion(body) +} + +// OwnerIsOrganisation asks GitHub whether owner is an organisation, whose +// apps are registered on the organisation's own page, or a person. +func OwnerIsOrganisation(ctx context.Context, c *http.Client, api, owner string) (bool, error) { + if !coresession.ValidOwner(owner) { + return false, fmt.Errorf("%q is not a GitHub user or organisation", owner) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, api+"/users/"+url.PathEscape(owner), nil) + if err != nil { + return false, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + resp, err := c.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("GitHub answered %d about %s", resp.StatusCode, owner) + } + var who struct { + Type string `json:"type"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&who); err != nil { + return false, err + } + switch who.Type { + case "Organization": + return true, nil + case "User": + return false, nil + } + return false, fmt.Errorf("GitHub names %s neither a person nor an organisation", owner) +} diff --git a/internal/surface/session/appsetup_test.go b/internal/surface/session/appsetup_test.go new file mode 100644 index 000000000..0a07066b9 --- /dev/null +++ b/internal/surface/session/appsetup_test.go @@ -0,0 +1,199 @@ +package session + +import ( + "context" + "encoding/json" + "html" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "sync" + "testing" + "time" + + coresession "github.com/intentdriven/abcd/internal/core/session" +) + +// fakeManifestGitHub answers the manifest conversion as GitHub does, secrets and +// all, and the owner lookup. +func fakeManifestGitHub(t *testing.T) (*httptest.Server, *[]string) { + t.Helper() + var mu sync.Mutex + var asked []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + asked = append(asked, r.Method+" "+r.URL.Path) + mu.Unlock() + switch { + case r.Method == http.MethodPost && r.URL.Path == "/app-manifests/code123/conversions": + if r.Header.Get("Authorization") != "" { + t.Errorf("the conversion sent credentials") + } + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"id": 7, "slug": "acme-brief", "client_id": "Iv23liAbC", "client_secret": "SECRET-ONE", + "webhook_secret": "SECRET-TWO", "pem": "SECRET-THREE", "owner": {"login": "acme", "type": "Organization"}}`) + case r.Method == http.MethodGet && r.URL.Path == "/users/acme": + io.WriteString(w, `{"login": "acme", "type": "Organization"}`) + case r.Method == http.MethodGet && r.URL.Path == "/users/octo": + io.WriteString(w, `{"login": "octo", "type": "User"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, &asked +} + +func newAppSetup(t *testing.T, api string) (*AppSetup, net.Listener) { + t.Helper() + m, err := coresession.NewAppManifest("acme/brief", "both", "", "https://brief-relay.onrender.com") + if err != nil { + t.Fatal(err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + return &AppSetup{ + Manifest: m, + FormURL: func(state string) string { return coresession.ManifestFormURL("acme", true, state) }, + API: api, + Client: &http.Client{Timeout: 5 * time.Second}, + }, ln +} + +var ( + formActionRe = regexp.MustCompile(`
      /auth/callback, and +// GitHub sends the person back there and nowhere else. GitHub matches a +// redirect_uri against the registered callback URLs exactly when wildcard +// matching is off, and, when it is on, by host and port and a path beneath +// the callback's; the user-authorization page says the redirect_uri "can't +// contain any additional parameters" and documents nothing about a query in +// a registered callback +// (https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url, +// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). +// So the relay a code is bound to is named by the callback's host, which both +// matching modes compare, rather than by a query on the page's address, which +// neither page promises to compare. +// +// The relay then sends the person on to the session page the sign-in started +// from, naming itself, and the page posts the code and its PKCE verifier to +// that relay alone. The exchange takes only a code given for this relay's +// own callback (serveToken), so a code reaches the relay GitHub returned the +// person to, and no other. + +// CallbackPath is the web sign-in's callback on the relay. +const CallbackPath = "/auth/callback" + +// maxSignInState bounds the state the page sends: its random part, a dot, +// and its own address, base64url. +const maxSignInState = 2048 + +// pageRouteRe is a session page's path after its base path: +// //brief/. The base path is the one the page's origin entry +// names (Server.pageBases), never read from the path itself: a static origin +// can carry other publishers' pages beside the session page, and a code is +// forwarded only to the page's own base path. +var pageRouteRe = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9._-]{1,100})/brief/$`) + +// ownOrigin is the relay's origin as the request reached it: https, or http +// for a loopback host, where a local relay is reached directly. +func ownOrigin(r *http.Request) string { + host := strings.ToLower(r.Host) + scheme := "https" + if h, _, err := splitHost(host); err == nil && isLoopbackName(h) { + scheme = "http" + } + return scheme + "://" + host +} + +// callbackURL is the relay's own callback, as GitHub was told it. +func callbackURL(r *http.Request) string { return ownOrigin(r) + CallbackPath } + +func splitHost(host string) (string, string, error) { + u, err := url.Parse("//" + host) + if err != nil { + return "", "", err + } + return u.Hostname(), u.Port(), nil +} + +func isLoopbackName(h string) bool { + return h == "localhost" || h == "127.0.0.1" || h == "::1" +} + +// statePage reads the session page a sign-in started from out of its state, +// and reports whether the relay may send a code there: an origin it admits, +// a session page's path under exactly the base path that origin's entry +// names, for a repository it serves, and nothing else. +func (s *Server) statePage(state string) (string, bool) { + if state == "" || len(state) > maxSignInState { + return "", false + } + _, enc, ok := strings.Cut(state, ".") + if !ok { + return "", false + } + raw, err := base64.RawURLEncoding.DecodeString(enc) + if err != nil { + return "", false + } + u, err := url.Parse(string(raw)) + if err != nil || !u.IsAbs() || u.User != nil || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || + u.Fragment != "" || strings.Contains(string(raw), "#") || u.RawPath != "" { + return "", false + } + origin, ok := normalizeOrigin(u.Scheme + "://" + u.Host) + if !ok || !s.originAllowed(origin) { + return "", false + } + for _, seg := range strings.Split(u.Path, "/") { + if seg == "." || seg == ".." { + return "", false + } + } + for _, base := range s.pageBases(origin) { + rest, under := strings.CutPrefix(u.Path, base) + if !under { + continue + } + if m := pageRouteRe.FindStringSubmatch(rest); m != nil && s.relay.Serves(m[1]+"/"+m[2]) { + return origin + u.Path, true + } + } + return "", false +} + +// serveCallback takes GitHub's return from the web sign-in and sends the +// person on to the session page the sign-in started from, with GitHub's code +// (or its error), the state, and this relay's own origin. It exchanges +// nothing itself: the PKCE verifier stays with the page until the page posts +// it here. +func (s *Server) serveCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.cfg.ClientID == "" || s.cfg.ClientSecret == "" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + refuse := func() { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "This sign-in cannot go back to its session page. Open the session page again and sign in from there.\n") + } + page, ok := s.statePage(q.Get("state")) + if !ok { + refuse() + return + } + out := url.Values{"state": {q.Get("state")}, "relay": {ownOrigin(r)}} + switch code, gerr := q.Get("code"), q.Get("error"); { + case code != "" && oauthCodeRe.MatchString(code): + out.Set("code", code) + case code == "" && githubErrorRe.MatchString(gerr): + out.Set("error", gerr) + default: + refuse() + return + } + http.Redirect(w, r, page+"?"+out.Encode(), http.StatusSeeOther) +} diff --git a/internal/surface/session/callback_test.go b/internal/surface/session/callback_test.go new file mode 100644 index 000000000..cfbf8d507 --- /dev/null +++ b/internal/surface/session/callback_test.go @@ -0,0 +1,196 @@ +package session + +import ( + "encoding/base64" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "testing" +) + +// noFollow is a client that reads a redirect rather than following it. +var noFollow = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + +// signInState is the state the page sends: a random part, a dot, and the +// page's own address, base64url. +func signInState(page string) string { + return "Zm9vYmFyYmF6cXV4cXV1eA." + base64.RawURLEncoding.EncodeToString([]byte(page)) +} + +func callback(t *testing.T, f *fixture, query url.Values) (*http.Response, string) { + t.Helper() + resp, err := noFollow.Get(f.http.URL + "/auth/callback?" + query.Encode()) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp, string(data) +} + +// TestTheCallbackReturnsTheCodeNamingItsOwnRelay: GitHub returns the person +// to the relay's own callback, the address the team's app registers, and the +// relay sends them on to the session page the sign-in started from, naming +// itself. The page then posts the code to that relay alone. +func TestTheCallbackReturnsTheCodeNamingItsOwnRelay(t *testing.T) { + f := newFixture(t, nil) + for _, page := range []string{"https://abcdesign.app/acme/brief/brief/"} { + state := signInState(page) + resp, body := callback(t, f, url.Values{"code": {"good-code"}, "state": {state}}) + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("%s: %d %s, want 303", page, resp.StatusCode, body) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + q := loc.Query() + if got := loc.Scheme + "://" + loc.Host + loc.Path; got != page { + t.Errorf("sent on to %s, want %s", got, page) + } + if q.Get("code") != "good-code" || q.Get("state") != state || q.Get("relay") != f.http.URL { + t.Errorf("sent on with %v, want the code, the state and relay %s", q, f.http.URL) + } + } + // GitHub's refusal goes back the same way, naming the relay. + resp, _ := callback(t, f, url.Values{"error": {"access_denied"}, "state": {signInState("https://abcdesign.app/acme/brief/brief/")}}) + loc, _ := url.Parse(resp.Header.Get("Location")) + if resp.StatusCode != http.StatusSeeOther || loc.Query().Get("error") != "access_denied" || loc.Query().Get("relay") != f.http.URL || loc.Query().Has("code") { + t.Errorf("a refusal: %d %s", resp.StatusCode, resp.Header.Get("Location")) + } +} + +// TestTheCallbackSendsNothingToAPageItDoesNotServe: the callback sends a code +// only to a session page on an origin the relay admits, for a repository it +// serves; anything else is refused with nothing sent on. +func TestTheCallbackSendsNothingToAPageItDoesNotServe(t *testing.T) { + f := newFixture(t, nil) + for name, q := range map[string]url.Values{ + "another origin": {"code": {"good-code"}, "state": {signInState("https://evil.example/acme/brief/brief/")}}, + "not a brief page": {"code": {"good-code"}, "state": {signInState("https://abcdesign.app/acme/brief/")}}, + "another repository": {"code": {"good-code"}, "state": {signInState("https://abcdesign.app/other/repo/brief/")}}, + "a query": {"code": {"good-code"}, "state": {signInState("https://abcdesign.app/acme/brief/brief/?x=1")}}, + "a fragment": {"code": {"good-code"}, "state": {signInState("https://abcdesign.app/acme/brief/brief/#x")}}, + "user info": {"code": {"good-code"}, "state": {signInState("https://a@abcdesign.app/acme/brief/brief/")}}, + "dot segments": {"code": {"good-code"}, "state": {signInState("https://abcdesign.app/x/../acme/brief/brief/")}}, + "no page": {"code": {"good-code"}, "state": {"Zm9vYmFyYmF6cXV4cXV1eA"}}, + "not base64": {"code": {"good-code"}, "state": {"Zm9vYmFyYmF6cXV4cXV1eA.!!!"}}, + "no code": {"state": {signInState("https://abcdesign.app/acme/brief/brief/")}}, + "a bad code": {"code": {"a b"}, "state": {signInState("https://abcdesign.app/acme/brief/brief/")}}, + "no state": {"code": {"good-code"}}, + } { + resp, body := callback(t, f, q) + if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("Location") != "" || strings.Contains(body, "good-code") { + t.Errorf("%s: %d %q %s, want 400 with nothing sent on", name, resp.StatusCode, resp.Header.Get("Location"), body) + } + } + // A relay without the secret offers no web sign-in, and so no callback. + g := newFixture(t, func(c *Config) { c.ClientSecret = "" }) + if resp, _ := callback(t, g, url.Values{"code": {"good-code"}, "state": {signInState("https://abcdesign.app/acme/brief/brief/")}}); resp.StatusCode != http.StatusNotFound { + t.Errorf("a relay without the secret: %d, want 404", resp.StatusCode) + } +} + +// TestTheCallbackForwardsOnlyToThePagesOwnBasePath: the callback forwards +// only to the page's own base path, the one its origin entry names, root +// when it names none. A static origin can carry other publishers' pages +// beside the session page, so a page under any other path on that origin is +// not the session page and is sent nothing. +func TestTheCallbackForwardsOnlyToThePagesOwnBasePath(t *testing.T) { + sends := func(f *fixture, page string) bool { + t.Helper() + resp, body := callback(t, f, url.Values{"code": {"good-code"}, "state": {signInState(page)}}) + switch resp.StatusCode { + case http.StatusSeeOther: + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil || loc.Scheme+"://"+loc.Host+loc.Path != page { + t.Errorf("%s: sent on to %s", page, resp.Header.Get("Location")) + } + return true + case http.StatusBadRequest: + if resp.Header.Get("Location") != "" || strings.Contains(body, "good-code") { + t.Errorf("%s: refused but sent on %q %s", page, resp.Header.Get("Location"), body) + } + return false + } + t.Fatalf("%s: %d %s", page, resp.StatusCode, body) + return false + } + // The default origin's page is at the root. + f := newFixture(t, nil) + for page, want := range map[string]bool{ + "https://abcdesign.app/acme/brief/brief/": true, + "https://abcdesign.app/sites/acme/brief/brief/": false, + "https://abcdesign.app/a/b/acme/brief/brief/": false, + "https://abcdesign.app/acme/brief/brief/x/": false, + "https://abcdesign.app/acme/brief/acme/brief/brief": false, + } { + if got := sends(f, page); got != want { + t.Errorf("default origins: %s sent=%v, want %v", page, got, want) + } + } + // An origin entry naming a base path admits the page there and nowhere + // else on that origin; an entry with none is the root. + g := newFixture(t, func(c *Config) { + c.Origins = []string{"https://pages.example/abcdesign/", "https://Other.Example"} + }) + for page, want := range map[string]bool{ + "https://pages.example/abcdesign/acme/brief/brief/": true, + "https://pages.example/acme/brief/brief/": false, + "https://pages.example/other/abcdesign/acme/brief/brief/": false, + "https://pages.example/abcdesign/x/acme/brief/brief/": false, + "https://pages.example/AbcDesign/acme/brief/brief/": false, + "https://other.example/acme/brief/brief/": true, + "https://other.example/abcdesign/acme/brief/brief/": false, + "https://abcdesign.app/acme/brief/brief/": false, + } { + if got := sends(g, page); got != want { + t.Errorf("configured origins: %s sent=%v, want %v", page, got, want) + } + } + // The browser's Origin carries no path, so the entry still admits its + // origin wherever an origin alone is judged. + for o, want := range map[string]bool{"https://pages.example": true, "https://other.example": true, "https://abcdesign.app": false} { + if got := g.srv.originAllowed(o); got != want { + t.Errorf("Origin %s admitted=%v, want %v", o, got, want) + } + } +} + +// TestTheExchangeTakesOnlyItsOwnCallback: the relay exchanges only a code +// GitHub gave for its own callback address. A code for any other address, +// the session page's included, or one sent with none, is refused before +// GitHub is asked, so a code can be redeemed only through the relay GitHub +// returned the person to. +func TestTheExchangeTakesOnlyItsOwnCallback(t *testing.T) { + f := newFixture(t, nil) + own := f.http.URL + "/auth/callback" + for _, redirect := range []string{"", "https://abcdesign.app/acme/brief/brief/", "https://other-relay.example/auth/callback", own + "?x=1", f.http.URL + "/auth/token"} { + body := `{"code":"good-code","code_verifier":"` + verifier + `"` + if redirect != "" { + body += `,"redirect_uri":` + strconv.Quote(redirect) + } + resp, got := post(t, f.http.URL+"/auth/token", origin, body+"}") + if resp.StatusCode != http.StatusBadRequest || strings.Contains(got, "ghu_") { + t.Errorf("redirect_uri %q: %d %s, want a refusal", redirect, resp.StatusCode, got) + } + } + f.github.mu.Lock() + n := len(f.github.exchanges) + f.github.mu.Unlock() + if n != 0 { + t.Fatalf("GitHub was asked %d times for codes the relay should refuse", n) + } + resp, got := post(t, f.http.URL+"/auth/token", origin, `{"code":"good-code","code_verifier":"`+verifier+`","redirect_uri":`+strconv.Quote(own)+`}`) + if resp.StatusCode != http.StatusOK || !strings.Contains(got, "ghu_session_token") { + t.Fatalf("its own callback: %d %s", resp.StatusCode, got) + } + f.github.mu.Lock() + sent := f.github.exchanges[0].Get("redirect_uri") + f.github.mu.Unlock() + if sent != own { + t.Errorf("GitHub was sent redirect_uri %q, want %q", sent, own) + } +} diff --git a/internal/surface/session/device.go b/internal/surface/session/device.go new file mode 100644 index 000000000..227e1c02c --- /dev/null +++ b/internal/surface/session/device.go @@ -0,0 +1,395 @@ +package session + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + core "github.com/intentdriven/abcd/internal/core/session" +) + +// GitHub's device flow, for a local session alone (`abcd session start`): a +// relay that serves its own session page and holds the GitHub App's client id +// and no secret, on the host's own computer, where no secret may sit +// (adr-2610161400542916). A relay that serves no page offers the web flow, +// with the secret, or no sign-in at all: the static page signs in through the +// web flow only. "The client_secret is not needed for the device +// flow" (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps). +// GitHub documents no CORS for its device endpoints, so the relay asks them +// for the page. +// +// The device code is the secret of a sign-in under way: whoever holds it +// collects the token once the person confirms. So the relay keeps it, under a +// random handle, and the page sees only the handle, the user code it shows the +// person, and the timings. The relay polls GitHub for a handle no faster than +// GitHub's interval, however often the page asks, so the number of sign-ins +// held (maxDeviceLogins) bounds what the relay can ask of GitHub, and the +// number one address holds (maxDeviceLoginsPerAddr) keeps a few addresses +// from taking every place. + +// deviceGrant is the device flow's grant type (RFC 8628 section 3.4). +const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code" + +// The device flow's own bounds. +const ( + // deviceInterval is GitHub's documented minimum between polls, and the + // interval taken when GitHub names none. + deviceInterval = 5 * time.Second + // slowDownStep is what GitHub adds to the interval on slow_down. + slowDownStep = 5 * time.Second + // maxDeviceLife caps how long a sign-in is held, whatever GitHub says: + // GitHub's own default is 900 seconds. + maxDeviceLife = 15 * time.Minute + // maxDeviceInterval caps the interval GitHub may ask for. + maxDeviceInterval = 60 * time.Second +) + +// deviceLogin is one device sign-in under way. +type deviceLogin struct { + code string + addr string // the client's address, as core.AddressKey counts it + interval time.Duration + next time.Time // GitHub is not asked before this + expires time.Time + asking bool // a poll is with GitHub now +} + +// deviceHandleRe is a handle the relay gave: 16 random bytes, base64url. +var deviceHandleRe = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) + +// userCodeRe bounds the user code GitHub hands out ("8 characters with a +// hyphen in the middle"), which the page shows. +var userCodeRe = regexp.MustCompile(`^[A-Z0-9]{4}-[A-Z0-9]{4}$`) + +// deviceOffered says the relay offers the device flow: a local session, +// which serves its own page, with a client id and no secret. A relay with the +// secret offers the web flow instead, and one that serves no page and holds +// no secret offers no sign-in. +func (s *Server) deviceOffered() bool { + return s.cfg.ServePage && s.cfg.ClientID != "" && s.cfg.ClientSecret == "" +} + +// devicesFrom counts the sign-ins under way for the address key addr. The +// caller holds s.mu. +func (s *Server) devicesFrom(addr string) int { + n := 0 + for _, d := range s.devices { + if d.addr == addr { + n++ + } + } + return n +} + +// deviceRoom reports whether another sign-in may be held: "" when it may, +// else "busy" (every place taken) or "rate-limited" (the address holds its +// share), with how long until the address's first sign-in ends and frees a +// place. The caller holds s.mu. +func (s *Server) deviceRoom(addr string, now time.Time) (string, time.Duration) { + switch { + case len(s.devices) >= maxDeviceLogins: + return "busy", 0 + case s.devicesFrom(addr) >= s.perAddrDevices(): + wait := maxDeviceLife + for _, d := range s.devices { + if d.addr == addr && d.expires.Sub(now) < wait { + wait = d.expires.Sub(now) + } + } + return "rate-limited", wait + } + return "", 0 +} + +// perAddrDevices is how many sign-ins one address may hold: never more than +// it may start in one exchange window. +func (s *Server) perAddrDevices() int { + return min(maxDeviceLoginsPerAddr, s.relay.Limits().MaxExchangesPerAddr) +} + +// refuseDeviceRoom answers a start deviceRoom refused. An address holding +// its share is told to come back when its first sign-in ends, in whole +// seconds, rounded up. +func (s *Server) refuseDeviceRoom(w http.ResponseWriter, why string, wait time.Duration) { + if why == "busy" { + w.Header().Set("Retry-After", "60") + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "busy"}) + return + } + w.Header().Set("Retry-After", strconv.Itoa(max(1, int((wait+time.Second-1)/time.Second)))) + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate-limited"}) +} + +func (s *Server) now() time.Time { + if s.cfg.Now != nil { + return s.cfg.Now() + } + return time.Now() +} + +// preflight answers a CORS preflight for a POST route, and reports whether r +// was one. +func (s *Server) preflight(w http.ResponseWriter, r *http.Request, allowed bool) bool { + if r.Method != http.MethodOptions { + return false + } + if !allowed { + http.Error(w, "origin not allowed", http.StatusForbidden) + return true + } + w.Header().Set("Access-Control-Allow-Methods", "POST") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return true +} + +// postFrom admits a POST from an allowed origin, answering anything else. +func (s *Server) postFrom(w http.ResponseWriter, r *http.Request) bool { + allowed := s.cors(w, r) + if s.preflight(w, r, allowed) { + return false + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + if !allowed { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "origin-not-allowed"}) + return false + } + return true +} + +// pruneDevices drops every sign-in whose device code has expired. The +// caller holds s.mu. +func (s *Server) pruneDevices(now time.Time) { + for h, d := range s.devices { + if !now.Before(d.expires) { + delete(s.devices, h) + } + } +} + +// serveDeviceStart starts a device sign-in: the relay asks GitHub for a +// device code, keeps it, and answers with a handle, the user code and +// GitHub's verification address. +func (s *Server) serveDeviceStart(w http.ResponseWriter, r *http.Request) { + if !s.postFrom(w, r) { + return + } + if !s.deviceOffered() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "device-flow-not-offered"}) + return + } + now := s.now() + client := clientAddress(r, s.cfg.BehindProxy, s.cfg.LoopbackOnly) + addr := core.AddressKey(client) + s.mu.Lock() + s.pruneDevices(now) + why, wait := s.deviceRoom(addr, now) + s.mu.Unlock() + if why != "" { + s.refuseDeviceRoom(w, why, wait) + return + } + // Counted with the web flow's exchanges, per address, before GitHub is + // asked: a device code is a request against the app's own limits. + if !s.relay.AllowExchange(client) { + w.Header().Set("Retry-After", strconv.Itoa(int(s.relay.Limits().ExchangeWindow/time.Second))) + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate-limited"}) + return + } + var got struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + Error string `json:"error"` + } + if !s.askGitHub(r.Context(), "/login/device/code", url.Values{"client_id": {s.cfg.ClientID}}, &got) { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "exchange-failed"}) + return + } + if got.DeviceCode == "" || !userCodeRe.MatchString(got.UserCode) { + code := "exchange-failed" + if got.Error != "" && githubErrorRe.MatchString(got.Error) { + code = got.Error // device_flow_disabled, above all + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": code}) + return + } + life := time.Duration(got.ExpiresIn) * time.Second + if life <= 0 || life > maxDeviceLife { + life = maxDeviceLife + } + interval := clampInterval(got.Interval, deviceInterval) + handle, err := s.newHandle() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "exchange-failed"}) + return + } + s.mu.Lock() + if why, wait := s.deviceRoom(addr, now); why != "" { + s.mu.Unlock() + s.refuseDeviceRoom(w, why, wait) + return + } + s.devices[handle] = &deviceLogin{code: got.DeviceCode, addr: addr, interval: interval, next: now.Add(interval), expires: now.Add(life)} + s.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{ + "handle": handle, + "user_code": got.UserCode, + "verification_uri": s.cfg.GitHubWeb + "/login/device", + "expires_in": int(life / time.Second), + "interval": int(interval / time.Second), + }) +} + +// clampInterval reads GitHub's interval in seconds: at least floor, at most +// maxDeviceInterval. +func clampInterval(seconds int, floor time.Duration) time.Duration { + d := time.Duration(seconds) * time.Second + if d < floor { + d = floor + } + if d > maxDeviceInterval { + d = maxDeviceInterval + } + return d +} + +// newHandle is 16 random bytes, base64url: a sign-in's handle. +func (s *Server) newHandle() (string, error) { + src := s.cfg.Rand + if src == nil { + src = rand.Reader + } + b := make([]byte, 16) + if _, err := io.ReadFull(src, b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// serveDevicePoll asks after a device sign-in. Before the sign-in's next +// poll is due the relay answers authorization_pending without asking GitHub; +// otherwise it asks once, and hands over the token when GitHub gives one. A +// sign-in GitHub ends (denied, expired, disabled) is dropped. +func (s *Server) serveDevicePoll(w http.ResponseWriter, r *http.Request) { + if !s.postFrom(w, r) { + return + } + var req struct { + Handle string `json:"handle"` + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAuthBody)) + if err := dec.Decode(&req); err != nil || !deviceHandleRe.MatchString(req.Handle) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad-request"}) + return + } + now := s.now() + s.mu.Lock() + s.pruneDevices(now) + d, ok := s.devices[req.Handle] + if !ok { + s.mu.Unlock() + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expired_token"}) + return + } + if d.asking || now.Before(d.next) { + interval := d.interval + s.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"error": "authorization_pending", "interval": int(interval / time.Second)}) + return + } + d.asking = true + code := d.code + s.mu.Unlock() + + var got struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error"` + Interval int `json:"interval"` + } + asked := s.askGitHub(r.Context(), "/login/oauth/access_token", url.Values{ + "client_id": {s.cfg.ClientID}, "device_code": {code}, "grant_type": {deviceGrant}, + }, &got) + + s.mu.Lock() + defer s.mu.Unlock() + d.asking = false + d.next = s.now().Add(d.interval) + switch { + case !asked: + // GitHub did not answer: the sign-in stays, and the page asks again. + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "exchange-failed"}) + case got.AccessToken != "": + delete(s.devices, req.Handle) + writeJSON(w, http.StatusOK, map[string]string{"access_token": got.AccessToken, "token_type": got.TokenType, "scope": got.Scope}) + case got.Error == "authorization_pending": + writeJSON(w, http.StatusOK, map[string]any{"error": "authorization_pending", "interval": int(d.interval / time.Second)}) + case got.Error == "slow_down": + // GitHub adds five seconds and names the new interval. + d.interval = clampInterval(got.Interval, d.interval+slowDownStep) + d.next = s.now().Add(d.interval) + writeJSON(w, http.StatusOK, map[string]any{"error": "slow_down", "interval": int(d.interval / time.Second)}) + default: + delete(s.devices, req.Handle) + out := "exchange-failed" + if githubErrorRe.MatchString(got.Error) { + out = got.Error + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": out}) + } +} + +// serveDeviceCancel ends a device sign-in the page stopped, so its place is +// free at once rather than held until GitHub's code expires. Whoever holds +// the handle may end it; an unknown handle is already ended. +func (s *Server) serveDeviceCancel(w http.ResponseWriter, r *http.Request) { + if !s.postFrom(w, r) { + return + } + var req struct { + Handle string `json:"handle"` + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAuthBody)) + if err := dec.Decode(&req); err != nil || !deviceHandleRe.MatchString(req.Handle) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad-request"}) + return + } + s.mu.Lock() + delete(s.devices, req.Handle) + s.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]bool{"ended": true}) +} + +// askGitHub posts form to GitHub's sign-in host at path and decodes its JSON +// answer into into, reporting whether GitHub answered with JSON at all. +func (s *Server) askGitHub(ctx context.Context, path string, form url.Values, into any) bool { + ctx, cancel := context.WithTimeout(ctx, githubTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.GitHubWeb+path, strings.NewReader(form.Encode())) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK && json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(into) == nil +} diff --git a/internal/surface/session/listen.go b/internal/surface/session/listen.go new file mode 100644 index 000000000..381b0bf37 --- /dev/null +++ b/internal/surface/session/listen.go @@ -0,0 +1,85 @@ +package session + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/netip" + "strconv" + "strings" + "time" +) + +// DefaultListen is where the relay listens unless --listen names another +// address: this computer alone (adr-2610032150581128, as amended 2026-10-14). +const DefaultListen = "127.0.0.1:8787" + +// The plain-HTTP bounds; a WebSocket is bounded by its pings once upgraded. +const ( + readHeaderTimeout = 10 * time.Second + idleTimeout = 60 * time.Second + maxHeaderBytes = 16 << 10 + shutdownWait = 5 * time.Second +) + +// ParseListen reads a --listen value: an IP address, or localhost, and a +// port. Port 0 lets the system choose one. A host name other than localhost +// is refused: the relay listens on an address, never on whatever a name +// resolves to. +func ParseListen(s string) (netip.AddrPort, error) { + host, port, err := net.SplitHostPort(strings.TrimSpace(s)) + if err != nil { + return netip.AddrPort{}, fmt.Errorf("--listen %q is not host:port", s) + } + n, err := strconv.ParseUint(port, 10, 16) + if err != nil { + return netip.AddrPort{}, fmt.Errorf("--listen %q: the port is not 0 to 65535", s) + } + if strings.EqualFold(host, "localhost") { + host = "127.0.0.1" + } + a, err := netip.ParseAddr(host) + if err != nil || a.Zone() != "" { + return netip.AddrPort{}, fmt.Errorf("--listen %q: give an IP address (or localhost), not a name", s) + } + return netip.AddrPortFrom(a.Unmap(), uint16(n)), nil +} + +// Listen opens the relay's listener: abcd's second, admitted by +// adr-2610032150581128's amendment of 2026-10-14. +func Listen(addr netip.AddrPort) (net.Listener, error) { + return net.Listen("tcp", addr.String()) +} + +// Run serves the relay on ln until ctx ends, purging idle sessions as it +// goes. The server's own error log goes nowhere: the relay logs nothing. +func (s *Server) Run(ctx context.Context, ln net.Listener) error { + srv := &http.Server{ + Handler: s, + ReadHeaderTimeout: readHeaderTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: maxHeaderBytes, + ErrorLog: log.New(io.Discard, "", 0), + BaseContext: func(net.Listener) context.Context { return ctx }, + } + go s.Sweep(ctx, SweepInterval) + served := make(chan error, 1) + go func() { served <- srv.Serve(ln) }() + select { + case <-ctx.Done(): + shut, cancel := context.WithTimeout(context.Background(), shutdownWait) + defer cancel() + _ = srv.Shutdown(shut) + <-served + return nil + case err := <-served: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} diff --git a/internal/surface/session/page.go b/internal/surface/session/page.go new file mode 100644 index 000000000..0064e661b --- /dev/null +++ b/internal/surface/session/page.go @@ -0,0 +1,84 @@ +package session + +import ( + "net/http" + "regexp" + "strings" +) + +// A local session's relay serves the session page itself, so nothing goes to +// abcdesign.app (adr-2610161400542916, and adr-2610032150581128 as amended +// 2026-10-16). It serves the page's embedded files from a fixed route table, +// never from a requested path: the four assets at the site's root, and the +// HTML, carrying web.RelayMarker, at ///brief/ for a repository +// the relay serves. + +// pageAssets are the page's files the relay serves, by path, with their +// content types. +var pageAssets = map[string]string{ + "/session.js": "text/javascript; charset=utf-8", + "/session.css": "text/css; charset=utf-8", + "/vendor/collab.js": "text/javascript; charset=utf-8", + "/vendor/LICENSES.txt": "text/plain; charset=utf-8", +} + +// briefPathRe is ///brief/, with or without its slash: the +// page adds it. +var briefPathRe = regexp.MustCompile(`^/([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9._-]{1,100})/brief/?$`) + +// servePage answers a page path, reporting whether path was one. Only GET +// and HEAD are taken. +func (s *Server) servePage(w http.ResponseWriter, r *http.Request) bool { + if !s.cfg.ServePage { + return false + } + path := r.URL.Path + h := w.Header() + if kind, ok := pageAssets[path]; ok { + if !pageMethod(w, r) { + return true + } + h.Set("Content-Type", kind) + _, _ = w.Write(s.page[strings.TrimPrefix(path, "/")]) + return true + } + if path == "/" && len(s.cfg.Repos) == 1 { + if !pageMethod(w, r) { + return true + } + http.Redirect(w, r, "/"+s.cfg.Repos[0]+"/brief/", http.StatusFound) + return true + } + m := briefPathRe.FindStringSubmatch(path) + if m == nil || m[2] == "." || m[2] == ".." || !s.relay.Serves(m[1]+"/"+m[2]) { + return false + } + if !pageMethod(w, r) { + return true + } + // The page refuses to run framed; a relay can also say so in headers, + // which a static host cannot. + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", "frame-ancestors 'none'") + _, _ = w.Write(s.page["index.html"]) + return true +} + +func pageMethod(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + return true +} + +// hostAllowed judges a request's Host against the names the relay answers: +// any, when none were given; otherwise exactly one of them, compared without +// case. +func (s *Server) hostAllowed(host string) bool { + if s.hosts == nil { + return true + } + return s.hosts[strings.ToLower(host)] +} diff --git a/internal/surface/session/page_test.go b/internal/surface/session/page_test.go new file mode 100644 index 000000000..2e15446bd --- /dev/null +++ b/internal/surface/session/page_test.go @@ -0,0 +1,134 @@ +package session + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/surface/session/web" +) + +// tailnetHost stands for the host computer's tailnet name in these tests. +const tailnetHost = "host.example.ts.net" + +// localFixture is a relay as `abcd session start` runs it: on loopback, +// serving its own page, admitting only its tailnet name and loopback. +func localFixture(t *testing.T) *fixture { + t.Helper() + f, _ := deviceFixture(t, func(c *Config) { + c.LoopbackOnly = true + c.ServePage = true + c.Hosts = []string{tailnetHost, "127.0.0.1:8787"} + c.Origins = []string{"https://" + tailnetHost, "http://127.0.0.1:8787"} + }) + return f +} + +// get asks the relay for path as a browser reaching it as host would. +func get(t *testing.T, f *fixture, host, path string) (*http.Response, string) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, f.http.URL+path, nil) + req.Host = host + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp, string(data) +} + +// TestALocalSessionServesItsOwnPage: the relay serves the page's embedded +// files from a fixed route table, the HTML only at the brief of a repository +// it serves and carrying the marker that tells the page its relay is its own +// origin; and the page is never framed (adr-2610161400542916). +func TestALocalSessionServesItsOwnPage(t *testing.T) { + f := localFixture(t) + resp, body := get(t, f, tailnetHost, "/acme/brief/brief/") + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") { + t.Fatalf("the brief: %d %s", resp.StatusCode, resp.Header.Get("Content-Type")) + } + if strings.Count(body, web.RelayMarker) != 1 { + t.Fatalf("the page does not carry the relay marker once:\n%s", body) + } + if resp.Header.Get("X-Frame-Options") != "DENY" || !strings.Contains(resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'") { + t.Errorf("the page may be framed: %v", resp.Header) + } + for path, kind := range map[string]string{ + "/session.js": "text/javascript", "/session.css": "text/css", "/vendor/collab.js": "text/javascript", "/vendor/LICENSES.txt": "text/plain", + } { + resp, body := get(t, f, tailnetHost, path) + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), kind) || body == "" { + t.Errorf("%s: %d %s", path, resp.StatusCode, resp.Header.Get("Content-Type")) + } + } + if resp, _ := get(t, f, tailnetHost, "/"); resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/acme/brief/brief/" { + t.Errorf("/: %d %s, want a redirect to the brief", resp.StatusCode, resp.Header.Get("Location")) + } + for _, path := range []string{"/other/repo/brief/", "/acme/brief/", "/../session.go", "/vendor/collab.entry.js", "/index.html", "/404.html", "/acme/brief/brief/x"} { + if resp, _ := get(t, f, tailnetHost, path); resp.StatusCode != http.StatusNotFound { + t.Errorf("%s: %d, want 404", path, resp.StatusCode) + } + } + // The relay's own routes still answer. + if resp, body := get(t, f, "127.0.0.1:8787", "/config"); resp.StatusCode != http.StatusOK || !strings.Contains(body, `"device"`) { + t.Errorf("/config: %d %s", resp.StatusCode, body) + } +} + +// TestALocalSessionAnswersOnlyItsOwnNames: any other host name is refused +// before anything is read, which is the DNS-rebinding guard, and only the +// tailnet page and loopback are admitted as origins. +func TestALocalSessionAnswersOnlyItsOwnNames(t *testing.T) { + f := localFixture(t) + for _, host := range []string{"evil.example", "evil.example:8787", "localhost.evil.example", ""} { + for _, path := range []string{"/acme/brief/brief/", "/config", "/healthz", "/session.js"} { + if resp, body := get(t, f, host, path); resp.StatusCode != http.StatusMisdirectedRequest || strings.Contains(body, "client_id") { + t.Errorf("host %q %s: %d, want 421", host, path, resp.StatusCode) + } + } + } + for _, o := range []string{"https://abcdesign.app", "http://localhost:9999", "https://evil.example"} { + req, _ := http.NewRequest(http.MethodPost, f.http.URL+"/auth/device", strings.NewReader(`{}`)) + req.Host = tailnetHost + req.Header.Set("Origin", o) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("origin %s: %d, want 403", o, resp.StatusCode) + } + } + // The tailnet page itself is admitted. + req, _ := http.NewRequest(http.MethodPost, f.http.URL+"/auth/device", strings.NewReader(`{}`)) + req.Host = tailnetHost + req.Header.Set("Origin", "https://"+tailnetHost) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("the tailnet page's device sign-in: %d, want 200", resp.StatusCode) + } +} + +// TestACloudRelayServesNoPage: without ServePage the relay serves no page +// file, as before: the page is the static one on abcdesign.app. +func TestACloudRelayServesNoPage(t *testing.T) { + f := newFixture(t, nil) + for _, path := range []string{"/acme/brief/brief/", "/session.js", "/vendor/collab.js"} { + resp, err := http.Get(f.http.URL + path) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("%s: %d, want 404", path, resp.StatusCode) + } + } +} diff --git a/internal/surface/session/server.go b/internal/surface/session/server.go new file mode 100644 index 000000000..86d5c6e8c --- /dev/null +++ b/internal/surface/session/server.go @@ -0,0 +1,1029 @@ +// Package session is the brief session relay's front door +// (itd-2610091016262335, spc-2610141709430146 step 1): the HTTP handler +// `abcd session serve` listens with. It serves these routes and nothing else: +// +// - /session, the WebSocket every participant's page holds, handed frame by +// frame to the transport-agnostic relay in internal/core/session; +// - /auth/callback, the web sign-in's callback the team's GitHub App +// registers: GitHub returns the person here, and the relay sends them on +// to the session page, naming itself (callback.go); +// - /auth/token, the sign-in exchange: the page posts GitHub's code and its +// PKCE verifier, and the relay adds the sign-in app's secret and asks +// GitHub for the token (the token-mediating backend of the IETF's +// browser-based-apps guidance), for a code given for its own callback +// alone; +// - /auth/device, /auth/device/token and /auth/device/cancel, the device +// sign-in of a local session (device.go); +// - /config, the sign-in app's public client id: always a GitHub App's; +// - /healthz, for the host's health check. +// +// It is abcd's second listener, admitted by adr-2610032150581128's amendment +// of 2026-10-14: started on purpose, on loopback unless an address is chosen, +// and reached on a person's machine through Tailscale Serve only. It writes +// nothing to disk and logs nothing: no token, no secret, no brief text. +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + core "github.com/intentdriven/abcd/internal/core/session" + "github.com/intentdriven/abcd/internal/core/site" + "github.com/intentdriven/abcd/internal/surface/session/web" +) + +// DefaultOrigin is the page's own origin. With loopback pages, for a page run +// locally, it is the default allowlist, which Config.Origins replaces. +const DefaultOrigin = "https://abcdesign.app" + +// AppTypeGitHubApp is the one kind of GitHub sign-in app the relay takes. A +// GitHub App's user access token holds only the permissions both the app and +// the person have, on the repositories the app is installed on, and its +// authorization takes no scope: "GitHub Apps do not request scopes during +// authorization to decide which resources the resulting token can access" +// (https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). +// An OAuth app's repo scope reaches every repository the person can, so a +// relay named by a stranger's repository could collect a token for all of +// them; GitHub recommends Apps over OAuth apps for the same reason +// (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps). +const AppTypeGitHubApp = "github-app" + +// The GitHub endpoints the relay talks to unless a test names others. +const ( + DefaultGitHubAPI = "https://api.github.com" + DefaultGitHubWeb = "https://github.com" +) + +// The server's own bounds. +const ( + maxAuthBody = 8 << 10 + githubTimeout = 15 * time.Second + writeTimeout = 10 * time.Second + // sendQueue is how many items one connection in a session may have + // waiting, a log replay counting as one; a connection that falls further + // behind is dropped. preRoomQueue is the bound before it enters a + // session, when nothing but answers to its own messages is sent to it. + sendQueue = 1024 + preRoomQueue = 64 + // SweepInterval is how often the relay looks for idle sessions. + SweepInterval = time.Minute + // pingEvery and pingWait keep a WebSocket honest: a connection that does + // not answer a ping in time is dropped, so a vanished device does not + // hold a participant's place. + pingEvery = 30 * time.Second + pingWait = 20 * time.Second + // httpWait bounds the reading and writing of a plain request; the + // WebSocket is bounded by its pings instead, since a deadline on the + // server would outlive the upgrade. + httpWait = 30 * time.Second + // DefaultCloseGrace is how long a connection the relay closed may take + // to end before its socket is torn down: its close may wait behind + // frames a page is slow to read, and the connection is counted until it + // ends. + DefaultCloseGrace = 5 * time.Second + // DefaultReplayWithin bounds one log replay: 8 MiB, the most a log + // holds, in a minute is about 1.1 Mbit/s. + DefaultReplayWithin = 60 * time.Second +) + +// Config is what the front door needs. +type Config struct { + // Origins, each an exact scheme://host[:port], replace the default + // allowlist (DefaultOrigin and any loopback page) when given: a relay + // serving a page from its own origin admits nothing else. An entry may + // go on to name the page's base path, as `abcd session page --base-path` + // writes it (https://host/abcdesign/): the web sign-in's callback then + // forwards only to a session page under exactly that path on that origin. + // An entry without one, and every default, is the page at the root. + Origins []string + // BehindProxy trusts the last X-Forwarded-For entry as the client's + // address, which is right only behind a proxy that sets it. + BehindProxy bool + // LoopbackOnly says the listener is bound to loopback alone: the host's + // own machine, reached through Tailscale Serve. Then, and only for a + // loopback peer, the tailnet address Serve forwards is the client's. + LoopbackOnly bool + // Repos (owner/name) and Owners are the repositories the relay serves + // sessions for (core.Options). A relay that is not LoopbackOnly must be + // given at least one: otherwise anyone signed in to GitHub could hold + // its sessions with a repository of their own. + Repos []string + Owners []string + // ClientID is the team's GitHub sign-in app's public id. + ClientID string + // ClientSecret is that app's secret, read from the environment by the + // caller. It never leaves the relay but in the exchange with GitHub. The + // app is a GitHub App: the relay takes no other kind. + ClientSecret string + // GitHubAPI and GitHubWeb default to GitHub's own. + GitHubAPI string + GitHubWeb string + // HTTPClient talks to GitHub; nil is a client with a timeout. + HTTPClient *http.Client + Limits core.Limits + Now func() time.Time + Rand io.Reader + // CloseGrace is how long a connection the relay closed may take to end: + // past it the socket is torn down, whatever it was still writing. Zero + // is DefaultCloseGrace. + CloseGrace time.Duration + // ReplayWithin bounds one log replay: a connection that has not taken + // the whole log within it is ended. Zero is DefaultReplayWithin. + ReplayWithin time.Duration + // ServePage serves the session page from the relay itself: a local + // session (`abcd session start`), where nothing goes to abcdesign.app. + ServePage bool + // Hosts, when given, are the only Host headers the relay answers, each a + // host or host:port; any other is refused with 421 before anything is + // read. A local session names its tailnet name and its loopback address. + Hosts []string +} + +// maxDeviceLogins bounds the device sign-ins one relay holds at once, and +// maxDeviceLoginsPerAddr those one client address holds (counted as the +// exchange limit counts it): fewer than the exchange limit lets an address +// start, so a few addresses cannot take every place. +const ( + maxDeviceLogins = 64 + maxDeviceLoginsPerAddr = 4 +) + +// Server is the relay's HTTP handler. +type Server struct { + cfg Config + origins map[string]bool // the given origins; nil admits the default list + // bases are the base paths each given origin's session page is served + // under, each as site.NormaliseBasePath returns it ("/" is the root). + bases map[string][]string + relay *core.Relay + client *http.Client + // hosts are the Host headers answered; nil answers any. + hosts map[string]bool + // page is the session page as the relay serves it, when ServePage. + page map[string][]byte + // peers is every live connection's queue, for queuedBytes; devices is + // every device sign-in under way, by handle. mu guards both. + mu sync.Mutex + peers map[*peer]struct{} + devices map[string]*deviceLogin +} + +// ErrNoRepositories is New's refusal of a relay beyond loopback that names +// no repository to serve. +var ErrNoRepositories = errors.New("a relay listening beyond this computer serves only the repositories it is given; name at least one") + +// New builds the handler, refusing an origin that is not an exact +// scheme://host[:port] over http or https, a repository that is not +// owner/name, an owner that is not a GitHub name, and a relay beyond +// loopback with neither. +func New(cfg Config) (*Server, error) { + for _, r := range cfg.Repos { + if !core.ValidRepo(r) { + return nil, fmt.Errorf("%q is not a repository: give owner/name", r) + } + } + for _, o := range cfg.Owners { + if !core.ValidOwner(o) { + return nil, fmt.Errorf("%q is not a GitHub owner", o) + } + } + if !cfg.LoopbackOnly && len(cfg.Repos) == 0 && len(cfg.Owners) == 0 { + return nil, ErrNoRepositories + } + var origins map[string]bool + var bases map[string][]string + for _, o := range cfg.Origins { + if origins == nil { + origins, bases = map[string]bool{}, map[string][]string{} + } + n, base, ok := normalizePageEntry(o) + if !ok { + return nil, fmt.Errorf("%q is not an origin: give scheme://host[:port], over http or https, with no wildcard, then the page's base path if it has one, as plain path segments", o) + } + origins[n] = true + if !slices.Contains(bases[n], base) { + bases[n] = append(bases[n], base) + } + } + if cfg.GitHubAPI == "" { + cfg.GitHubAPI = DefaultGitHubAPI + } + if cfg.GitHubWeb == "" { + cfg.GitHubWeb = DefaultGitHubWeb + } + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: githubTimeout} + } + // A redirect would carry the request somewhere GitHub did not answer + // from; the relay follows none. + c := *client + c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + if c.Timeout == 0 { + c.Timeout = githubTimeout + } + if cfg.CloseGrace <= 0 { + cfg.CloseGrace = DefaultCloseGrace + } + if cfg.ReplayWithin <= 0 { + cfg.ReplayWithin = DefaultReplayWithin + } + var hosts map[string]bool + for _, h := range cfg.Hosts { + if hosts == nil { + hosts = map[string]bool{} + } + h = strings.ToLower(strings.TrimSpace(h)) + if h == "" || strings.ContainsAny(h, "/*?#@ ") { + return nil, fmt.Errorf("%q is not a host name", h) + } + hosts[h] = true + } + s := &Server{cfg: cfg, origins: origins, bases: bases, hosts: hosts, client: &c, peers: map[*peer]struct{}{}, devices: map[string]*deviceLogin{}} + if cfg.ServePage { + s.page = web.RelayFiles() + } + s.relay = core.New(core.Options{ + Limits: cfg.Limits, + Verifier: GitHub{API: cfg.GitHubAPI, Client: &c}, + Now: cfg.Now, + Rand: cfg.Rand, + Repos: cfg.Repos, + Owners: cfg.Owners, + }) + return s, nil +} + +// Relay is the relay the handler feeds. +func (s *Server) Relay() *core.Relay { return s.relay } + +// Sweep purges idle sessions every interval until ctx ends. +func (s *Server) Sweep(ctx context.Context, interval time.Duration) { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.relay.Sweep() + } + } +} + +// ServeHTTP is the fixed route table. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Cache-Control", "no-store") + h.Set("Referrer-Policy", "no-referrer") + if r.URL.Path != "/session" { + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Now().Add(httpWait)) + _ = rc.SetWriteDeadline(time.Now().Add(httpWait)) + } + // A relay that names its hosts answers no other, before anything is + // read: a page on another name that resolves to this computer (DNS + // rebinding) gets nothing. + if !s.hostAllowed(r.Host) { + http.Error(w, "misdirected request", http.StatusMisdirectedRequest) + return + } + switch r.URL.Path { + case "/session": + s.serveSession(w, r) + case "/auth/token": + s.serveToken(w, r) + case CallbackPath: + s.serveCallback(w, r) + case "/auth/device": + s.serveDeviceStart(w, r) + case "/auth/device/token": + s.serveDevicePoll(w, r) + case "/auth/device/cancel": + s.serveDeviceCancel(w, r) + case "/config": + s.serveConfig(w, r) + case "/healthz": + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + h.Set("Content-Type", "text/plain; charset=utf-8") + _, _ = io.WriteString(w, "ok\n") + default: + if !s.servePage(w, r) { + http.NotFound(w, r) + } + } +} + +// normalizeOrigin reads s as an exact origin: http or https, a host, an +// optional port, and nothing else. The scheme's default port (443 for https, +// 80 for http) is dropped, as a browser's origin drops it +// (https://url.spec.whatwg.org/#origin), so an entry naming it admits the +// Origin a browser sends. +func normalizeOrigin(s string) (string, bool) { + u, err := url.Parse(s) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || + u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || + u.Hostname() == "" || strings.ContainsAny(u.Hostname(), "*?[]") { + return "", false + } + host := strings.ToLower(u.Host) + if def := map[string]string{"https": ":443", "http": ":80"}[u.Scheme]; strings.HasSuffix(host, def) { + host = strings.TrimSuffix(host, def) + } + return u.Scheme + "://" + host, true +} + +// normalizePageEntry reads one origin entry: an exact origin, as +// normalizeOrigin reads it, then optionally the page's base path, plain path +// segments by the site builder's own rule (site.NormaliseBasePath, which +// `abcd session page --base-path` applies too), with or without its trailing +// slash. It returns the origin and the base path, "/" for none. +func normalizePageEntry(s string) (string, string, bool) { + u, err := url.Parse(s) + if err != nil || u.RawPath != "" || u.Opaque != "" { + return "", "", false + } + base, err := site.NormaliseBasePath(u.Path) + if err != nil { + return "", "", false + } + u.Path = "" + n, ok := normalizeOrigin(u.String()) + if !ok { + return "", "", false + } + return n, base, true +} + +// pageBases are the base paths a session page on origin (as normalizeOrigin +// returns it) is served under: the ones its entry names, or, with no origins +// given, the root of every origin the default list admits. +func (s *Server) pageBases(origin string) []string { + if s.origins != nil { + return s.bases[origin] + } + if s.originAllowed(origin) { + return []string{"/"} + } + return nil +} + +// originAllowed judges a request's Origin: one of the given origins exactly, +// or, when none was given, DefaultOrigin or a loopback page. A request with +// no Origin is refused: every browser sends one on a WebSocket and on a +// cross-origin POST. +func (s *Server) originAllowed(origin string) bool { + n, ok := normalizeOrigin(origin) + if !ok { + return false + } + if s.origins != nil { + return s.origins[n] + } + if n == DefaultOrigin { + return true + } + u, _ := url.Parse(n) + switch u.Hostname() { + case "localhost", "127.0.0.1", "::1": + return true + } + return false +} + +// clientAddress is the address the failed-join limit counts. +// +// - Behind a declared proxy (--behind-proxy, the team relay), it is the +// rightmost X-Forwarded-For entry, across every line of the header: the +// one that proxy appended, one trusted hop. Anything before it the client +// could have written. Render's proxy appends rather than replaces, "so a +// caller who sends their own copy of the header controls the leftmost +// entry" (https://render.com/articles/host-pocketbase-on-render), and +// Render's advice is to trust one hop, Express's 'trust proxy' 1, which +// reads the rightmost entry +// (https://render.com/articles/how-render-handles-ddos-attacks). An entry +// that is not a bare IP address reads as missing. +// - On the host's own machine the relay is bound to loopback alone and +// Tailscale Serve proxies to it, so every tailnet visitor's socket address +// is loopback and one visitor's wrong codes would shut out everyone. There, +// and only when the socket's peer is itself loopback, it is the address +// Serve forwards (tailnetForwarded). +// - Otherwise, and whenever the header is missing or malformed, it is the +// socket's peer. +func clientAddress(r *http.Request, behindProxy, loopbackOnly bool) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if behindProxy { + if fwd := r.Header.Values("X-Forwarded-For"); len(fwd) > 0 { + parts := strings.Split(fwd[len(fwd)-1], ",") + if a, err := netip.ParseAddr(strings.TrimSpace(parts[len(parts)-1])); err == nil { + return a.Unmap().WithZone("").String() + } + } + return host + } + if loopbackOnly { + if peer, err := netip.ParseAddr(host); err == nil && peer.Unmap().IsLoopback() { + if fwd, ok := tailnetForwarded(r); ok { + return fwd + } + } + } + return host +} + +// tailnetForwarded reads the visitor's address as Tailscale Serve forwards +// it. Serve's reverse proxy sets X-Forwarded-For to the tailnet address of the +// device that connected, replacing whatever the device sent, alongside +// X-Forwarded-Host, X-Forwarded-Proto (https) and, for a device that is not +// tagged, the Tailscale-User-Login, Tailscale-User-Name and +// Tailscale-User-Profile-Pic identity headers, which it strips from the +// incoming request first: +// https://tailscale.com/kb/1312/serve (the identity headers) and +// https://github.com/tailscale/tailscale/blob/main/ipn/ipnlocal/serve.go +// (addProxyForwardedHeaders, which sets X-Forwarded-For). So the header is +// accepted only in the shape Serve writes it: one line holding one address +// in Tailscale's ranges. Anything else is not Serve's, and reads as missing. +func tailnetForwarded(r *http.Request) (string, bool) { + fwd := r.Header.Values("X-Forwarded-For") + if len(fwd) != 1 { + return "", false + } + a, err := netip.ParseAddr(strings.TrimSpace(fwd[0])) + if err != nil || a.Zone() != "" || !core.IsTailnetAddr(a) { + return "", false + } + return a.Unmap().String(), true +} + +// serveSession upgrades to the session's WebSocket and runs one connection. +// Past the relay's connection caps the upgrade is refused with 503, before +// anything is allocated for it and without touching another connection. +func (s *Server) serveSession(w http.ResponseWriter, r *http.Request) { + if !s.originAllowed(r.Header.Get("Origin")) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return + } + // The request's context ends when the server shuts down (Run's + // BaseContext), which ends every session connection with it. + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + p := newPeer(cancel, preRoomQueue, s.relay.Limits().MaxQueuedBytes) + p.grace, p.replayWithin = s.cfg.CloseGrace, s.cfg.ReplayWithin + conn, err := s.relay.Connect(p, clientAddress(r, s.cfg.BehindProxy, s.cfg.LoopbackOnly)) + if err != nil { + http.Error(w, "the relay is busy; try again later", http.StatusServiceUnavailable) + return + } + s.mu.Lock() + s.peers[p] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.peers, p) + s.mu.Unlock() + }() + // The origin was judged above against the relay's own allowlist, which + // the library's same-host rule would widen. + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + conn.Leave() + return + } + ws.SetReadLimit(int64(s.relay.Limits().MaxMessageBytes)) + p.mu.Lock() + p.ws = ws + p.mu.Unlock() + expire := time.AfterFunc(s.relay.Limits().AdmitWithin, conn.Expire) + defer expire.Stop() + done := make(chan struct{}) + go func() { p.writeLoop(ctx); close(done) }() + go p.pingLoop(ctx) + for { + typ, data, err := ws.Read(ctx) + if err != nil { + break + } + conn.Receive(ctx, core.Frame{Binary: typ == websocket.MessageBinary, Data: data}) + } + cancel() + <-done + _ = ws.CloseNow() + p.stopGrace() + // The socket is gone: only now does the connection give its place in + // the relay's caps back. A connection the relay closed earlier stayed + // counted while it finished writing. + conn.Leave() +} + +// outFrame is one queued item: a frame, a session's log replay, or the close +// that ends the queue. size is what it is charged against the byte cap. +type outFrame struct { + frame core.Frame + log [][]byte + close bool + status websocket.StatusCode + reason string + size int +} + +// peer is the relay's handle on one WebSocket: a queue its writer drains in +// order, so a close the relay asks for follows every item queued before it; +// but a close asked for during a log replay stops the replay at the next +// entry and is written at once, dropping what was queued after the replay, +// and whatever is still waiting, the socket ends within its close grace. +// The queue holds at most limit items and maxBytes bytes of frames; a log +// replay is one item however long the log, written frame by frame as the +// connection takes them, and is charged no bytes: its entries are the +// session's own, held whether or not they wait here. +type peer struct { + ws *websocket.Conn + cancel context.CancelFunc + wake chan struct{} // holds one token while items wait + mu sync.Mutex + queue []outFrame + limit int + bytes int // the frames' bytes waiting + maxBytes int + closed bool + // closing is the close the relay asked for, once it has. + closing outFrame + // grace is how long a closed connection may take to end, and + // graceTimer tears it down when that has passed. + grace time.Duration + graceTimer *time.Timer + // replayWithin bounds one log replay. + replayWithin time.Duration +} + +func newPeer(cancel context.CancelFunc, limit, maxBytes int) *peer { + return &peer{cancel: cancel, wake: make(chan struct{}, 1), limit: limit, maxBytes: maxBytes, + grace: DefaultCloseGrace, replayWithin: DefaultReplayWithin} +} + +// queuedBytes is what the peer's queue holds now, in bytes. +func (p *peer) queuedBytes() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.bytes +} + +// queuedBytes is what every live connection's queue holds together. +func (s *Server) queuedBytes() int { + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for p := range s.peers { + n += p.queuedBytes() + } + return n +} + +// push queues one item under the lock, or reports that the queue is full: +// past its items or its bytes. A full queue is dropped at once and the +// connection ended, so a peer that stopped reading holds nothing more while +// the relay lets it go. +func (p *peer) push(f outFrame) bool { + if len(p.queue) >= p.limit || p.bytes+f.size > p.maxBytes { + for i := range p.queue { + p.queue[i] = outFrame{} + } + p.queue, p.bytes = nil, 0 + p.cancel() + return false + } + p.bytes += f.size + p.queue = append(p.queue, f) + select { + case p.wake <- struct{}{}: + default: + } + return true +} + +func (p *peer) Send(f core.Frame) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return true + } + return p.push(outFrame{frame: f, size: len(f.Data)}) +} + +func (p *peer) SendLog(entries [][]byte) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return true + } + return p.push(outFrame{log: entries}) +} + +// Entered lets a connection that is in a session have the full queue. +func (p *peer) Entered() { + p.mu.Lock() + defer p.mu.Unlock() + p.limit = sendQueue +} + +func (p *peer) Close(policy bool, reason string) { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return + } + p.closed = true + status := websocket.StatusNormalClosure + if policy { + status = websocket.StatusPolicyViolation + } + p.closing = outFrame{close: true, status: status, reason: reason} + p.push(p.closing) + // Whatever it is still writing, the connection ends within its grace. + p.graceTimer = time.AfterFunc(p.grace, p.abort) +} + +// abort tears the connection down at once: the context its reads and writes +// run under ends, and so does the socket. +func (p *peer) abort() { + p.cancel() + p.mu.Lock() + ws := p.ws + p.mu.Unlock() + if ws != nil { + _ = ws.CloseNow() + } +} + +// stopGrace stops the close grace's timer once the connection has ended. +func (p *peer) stopGrace() { + p.mu.Lock() + defer p.mu.Unlock() + if p.graceTimer != nil { + p.graceTimer.Stop() + } +} + +// closeAsked returns the close the relay asked for, if it has. +func (p *peer) closeAsked() (outFrame, bool) { + p.mu.Lock() + defer p.mu.Unlock() + return p.closing, p.closed +} + +// next takes the oldest queued item, waiting for one until ctx ends. +func (p *peer) next(ctx context.Context) (outFrame, bool) { + for { + p.mu.Lock() + if len(p.queue) > 0 { + f := p.queue[0] + p.queue[0] = outFrame{} + p.queue = p.queue[1:] + p.bytes -= f.size + p.mu.Unlock() + return f, true + } + p.mu.Unlock() + select { + case <-ctx.Done(): + return outFrame{}, false + case <-p.wake: + } + } +} + +// pingLoop pings the connection until ctx ends, and ends the connection when +// a ping goes unanswered. +func (p *peer) pingLoop(ctx context.Context) { + t := time.NewTicker(pingEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + pctx, cancel := context.WithTimeout(ctx, pingWait) + err := p.ws.Ping(pctx) + cancel() + if err != nil { + p.cancel() + return + } + } + } +} + +func (p *peer) writeLoop(ctx context.Context) { + for { + f, ok := p.next(ctx) + if !ok { + return + } + if f.close { + _ = p.ws.Close(f.status, f.reason) + p.cancel() + return + } + if f.log != nil { + if !p.replay(ctx, f.log) { + return + } + continue + } + if !p.write(ctx, f.frame, time.Time{}) { + return + } + } +} + +// replay writes a log replay entry by entry, within the peer's replay bound. +// A close the relay asks for meanwhile stops it at the next entry: the rest +// of the log, and everything queued after it, is dropped and the close is +// written at once. A replay past its bound ends the connection. It reports +// whether the connection carries on. +func (p *peer) replay(ctx context.Context, log [][]byte) bool { + deadline := time.Now().Add(p.replayWithin) + for _, entry := range log { + if c, closed := p.closeAsked(); closed { + _ = p.ws.Close(c.status, c.reason) + p.cancel() + return false + } + if !time.Now().Before(deadline) { + p.cancel() + return false + } + if !p.write(ctx, core.Frame{Binary: true, Data: entry}, deadline) { + return false + } + } + return true +} + +// write writes one frame, by deadline when one is given and within +// writeTimeout in any case, ending the connection when it cannot. +func (p *peer) write(ctx context.Context, f core.Frame, deadline time.Time) bool { + typ := websocket.MessageText + if f.Binary { + typ = websocket.MessageBinary + } + by := time.Now().Add(writeTimeout) + if !deadline.IsZero() && deadline.Before(by) { + by = deadline + } + wctx, cancel := context.WithDeadline(ctx, by) + err := p.ws.Write(wctx, typ, f.Data) + cancel() + if err != nil { + p.cancel() + return false + } + return true +} + +// writeJSON answers with a JSON body. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// cors answers a request from an allowed origin with the headers a browser +// needs to read the answer, and reports whether the origin is allowed. +func (s *Server) cors(w http.ResponseWriter, r *http.Request) bool { + w.Header().Add("Vary", "Origin") + origin := r.Header.Get("Origin") + if !s.originAllowed(origin) { + return false + } + w.Header().Set("Access-Control-Allow-Origin", origin) + return true +} + +// serveConfig names the sign-in app's public client id, whether sign-in is +// set up at all, and which flow it takes; with a client id, its kind is always +// a GitHub App's, since the relay takes no other. It never names the secret. The authorize +// URL is bare: it carries no scope, which a GitHub App does not use, and the +// page adds the request's own parameters. +func (s *Server) serveConfig(w http.ResponseWriter, r *http.Request) { + s.cors(w, r) + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // With the app's secret, GitHub's web flow through /auth/token; in a + // local session, which serves its own page, with the client id alone, + // the device flow through /auth/device. A relay that serves no page and + // holds no secret offers no sign-in. + signIn := s.cfg.ClientID != "" && (s.cfg.ClientSecret != "" || s.deviceOffered()) + cfg := map[string]any{ + "client_id": s.cfg.ClientID, + "sign_in": signIn, + "authorize_url": s.cfg.GitHubWeb + "/login/oauth/authorize", + } + if s.cfg.ClientID != "" { + cfg["app_type"] = AppTypeGitHubApp + } + if signIn { + cfg["flow"] = "web" + if s.deviceOffered() { + cfg["flow"] = "device" + } + } + writeJSON(w, http.StatusOK, cfg) +} + +// pkceVerifierRe is RFC 7636's code_verifier: 43 to 128 unreserved characters. +var pkceVerifierRe = regexp.MustCompile(`^[A-Za-z0-9._~-]{43,128}$`) + +// oauthCodeRe bounds GitHub's authorization code. +var oauthCodeRe = regexp.MustCompile(`^[A-Za-z0-9._~-]{1,256}$`) + +// githubErrorRe is the shape of an OAuth error code; anything else GitHub +// says is not passed on. +var githubErrorRe = regexp.MustCompile(`^[a-z_]{1,64}$`) + +// serveToken is the sign-in exchange. +func (s *Server) serveToken(w http.ResponseWriter, r *http.Request) { + if !s.postFrom(w, r) { + return + } + if s.cfg.ClientID == "" || s.cfg.ClientSecret == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "sign-in-not-configured"}) + return + } + // Each address may try the exchange a few times in a window, counted as + // wrong join codes are, so the relay cannot be used to hammer GitHub + // with codes; a refused try asks nothing of GitHub. + if !s.relay.AllowExchange(clientAddress(r, s.cfg.BehindProxy, s.cfg.LoopbackOnly)) { + w.Header().Set("Retry-After", strconv.Itoa(int(s.relay.Limits().ExchangeWindow/time.Second))) + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate-limited"}) + return + } + var req struct { + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` + RedirectURI string `json:"redirect_uri"` + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAuthBody)) + if err := dec.Decode(&req); err != nil || !oauthCodeRe.MatchString(req.Code) || !pkceVerifierRe.MatchString(req.CodeVerifier) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad-request"}) + return + } + // Only a code GitHub gave for this relay's own callback is exchanged: + // GitHub sends a code only to a callback the team's app registers, so a + // code reaches the relay the person was returned to, and no other relay + // can pass one on here (callback.go). + if req.RedirectURI != callbackURL(r) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad-redirect"}) + return + } + form := url.Values{ + "client_id": {s.cfg.ClientID}, + "client_secret": {s.cfg.ClientSecret}, + "code": {req.Code}, + "code_verifier": {req.CodeVerifier}, + "redirect_uri": {req.RedirectURI}, + } + ctx, cancel := context.WithTimeout(r.Context(), githubTimeout) + defer cancel() + greq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.GitHubWeb+"/login/oauth/access_token", strings.NewReader(form.Encode())) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "exchange-failed"}) + return + } + greq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + greq.Header.Set("Accept", "application/json") + resp, err := s.client.Do(greq) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "exchange-failed"}) + return + } + defer resp.Body.Close() + var got struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error"` + } + if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&got) != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "exchange-failed"}) + return + } + // Only named fields pass, and none that carries the secret. + for _, v := range []string{got.AccessToken, got.TokenType, got.Scope, got.Error} { + if strings.Contains(v, s.cfg.ClientSecret) { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "exchange-failed"}) + return + } + } + if got.AccessToken == "" { + code := "exchange-failed" + if githubErrorRe.MatchString(got.Error) { + code = got.Error + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": code}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"access_token": got.AccessToken, "token_type": got.TokenType, "scope": got.Scope}) +} + +// GitHub is the relay's Verifier: it asks GitHub's API who a token belongs to +// and whether that login may push to the repository. +type GitHub struct { + API string + Client *http.Client +} + +// errVerify is the one error a failed verify reports; it never carries the +// token or what GitHub said. +var errVerify = errors.New("GitHub did not accept the token") + +// userTokenPrefix is the prefix GitHub gives a GitHub App's user access +// token, and no other kind: "ghu_ for GitHub user-to-server tokens", beside +// ghp_ for a personal access token, gho_ for an OAuth app's and ghs_ for an +// installation's +// (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#githubs-token-formats). +const userTokenPrefix = "ghu_" + +// Verify answers the login and the repository's permissions.push. A +// repository GitHub hides from the token (404) is no push, not an error. +// +// It takes only a GitHub App's user access token, refusing any other before +// GitHub is asked: nobody pastes a token (adr-2610161400542916), and a GitHub +// App's token reaches only the repositories the app is installed on. +func (g GitHub) Verify(ctx context.Context, token, repo string) (string, bool, error) { + if !core.ValidRepo(repo) { + return "", false, errors.New("not an owner/name repository") + } + if !strings.HasPrefix(token, userTokenPrefix) || len(token) > 255 { + return "", false, errVerify + } + var user struct { + Login string `json:"login"` + } + status, err := g.get(ctx, token, "/user", &user) + if err != nil || status != http.StatusOK || user.Login == "" { + return "", false, errVerify + } + var rp struct { + Permissions struct { + Push bool `json:"push"` + } `json:"permissions"` + } + status, err = g.get(ctx, token, "/repos/"+repo, &rp) + if err != nil { + return "", false, errVerify + } + if status != http.StatusOK { + return user.Login, false, nil + } + return user.Login, rp.Permissions.Push, nil +} + +func (g GitHub) get(ctx context.Context, token, path string, into any) (int, error) { + ctx, cancel := context.WithTimeout(ctx, githubTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.API+path, nil) + if err != nil { + return 0, errVerify + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + client := g.Client + if client == nil { + client = &http.Client{Timeout: githubTimeout} + } + resp, err := client.Do(req) + if err != nil { + return 0, errVerify + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(into) != nil { + return 0, errVerify + } + } + return resp.StatusCode, nil +} diff --git a/internal/surface/session/server_bounds_test.go b/internal/surface/session/server_bounds_test.go new file mode 100644 index 000000000..5604bee98 --- /dev/null +++ b/internal/surface/session/server_bounds_test.go @@ -0,0 +1,189 @@ +package session + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/coder/websocket" + core "github.com/intentdriven/abcd/internal/core/session" +) + +// livePeers is how many sockets the transport is serving now. +func (s *Server) livePeers() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.peers) +} + +// bigLog has the host log n updates of size bytes each, and returns the join +// code once the relay has handled them all. +func bigLog(t *testing.T, host *client, n, size int) string { + t.Helper() + for i := 0; i < n; i++ { + b := make([]byte, size) + b[0] = core.TagUpdate + b[1] = byte(i) + host.sendBinary(b) + } + host.sendJSON(map[string]string{"type": "rotate"}) + got, _ := host.until("code") + return got["code"].(string) +} + +// joinSlow joins by code, or rejoins by token when code is empty, and reads +// nothing past the joined answer: its log replay waits on a socket nobody +// reads. It returns the client and its rejoin token. +func (f *fixture) joinSlow(t *testing.T, code, token string) (*client, string) { + t.Helper() + c := f.dial(t, origin) + c.ws.SetReadLimit(1 << 20) + if code != "" { + c.sendJSON(map[string]string{"type": "join", "code": code, "name": "Gus"}) + } else { + c.sendJSON(map[string]string{"type": "rejoin", "token": token}) + } + j, _ := c.until("joined") + tok, _ := j["rejoin"].(string) + return c, tok +} + +// slowLogFixture is a relay whose log is far larger than a socket's buffers, +// so a replay to a page that reads nothing stays in progress. +func slowLogFixture(t *testing.T, mod func(*Config)) *fixture { + return newFixture(t, func(c *Config) { + c.Limits.UpdatesPerSecond, c.Limits.UpdateBurst = 1<<20, 1<<20 + c.Limits.MaxLogBytes = 64 << 20 + if mod != nil { + mod(c) + } + }) +} + +// TestARejoinDuringASlowReplayStaysBounded reproduces the final review's +// finding A: a guest whose log replay is in progress rejoins on a new socket, +// again and again. The relay closes each old connection, but the close waits +// behind the replay; it used to give the old socket's place back at once, so +// the sockets held grew past every cap, counted nowhere. Now each stays +// counted until its transport ends it, past the per-address cap the next is +// refused, and each old socket ends within the close grace. +func TestARejoinDuringASlowReplayStaysBounded(t *testing.T) { + const perAddr = 4 + f := slowLogFixture(t, func(c *Config) { + c.Limits.MaxConnsPerAddr = perAddr + c.CloseGrace = 500 * time.Millisecond + }) + host, _ := f.hostSession(t) + code := bigLog(t, host, 60, 250<<10) + _, tok := f.joinSlow(t, code, "") + refused := false + for i := 0; i < 6; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ws, resp, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {origin}}}) + cancel() + if err != nil { + if resp == nil || resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("rejoin %d: %v", i+1, err) + } + refused = true + break + } + t.Cleanup(func() { _ = ws.CloseNow() }) + c := &client{t: t, ws: ws} + c.sendJSON(map[string]string{"type": "rejoin", "token": tok}) + j, _ := c.until("joined") + tok, _ = j["rejoin"].(string) + if live, counted := f.srv.livePeers(), f.srv.Relay().Stats().Connections; live > perAddr || counted < live { + t.Fatalf("after rejoin %d: %d sockets served, %d counted; want at most %d and every one counted", i+1, live, counted, perAddr) + } + } + if !refused { + t.Fatalf("six rejoins in a burst were never refused: the old sockets are not counted (%d served)", f.srv.livePeers()) + } + // Each old socket ends within the grace, its replay abandoned, and gives + // its place back: the host and the newest rejoin are left. + deadline := time.Now().Add(3 * time.Second) + for f.srv.livePeers() > 2 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if live, counted := f.srv.livePeers(), f.srv.Relay().Stats().Connections; live != 2 || counted != 2 { + t.Fatalf("after the close grace: %d sockets served, %d counted; want 2 and 2", live, counted) + } +} + +// TestAClosedConnectionStopsItsReplay: a page reading its replay slowly is +// closed by a rejoin elsewhere; the rest of its replay is dropped and the +// close comes next, well before the log would have ended, without waiting for +// the close grace. +func TestAClosedConnectionStopsItsReplay(t *testing.T) { + const n = 60 + f := slowLogFixture(t, func(c *Config) { c.CloseGrace = time.Minute }) + host, _ := f.hostSession(t) + code := bigLog(t, host, n, 250<<10) + slow, tok := f.joinSlow(t, code, "") + // A few entries read, then the participant rejoins elsewhere. + for i := 0; i < 3; i++ { + if _, b, err := slow.next(); err != nil || b == nil { + t.Fatalf("replay entry %d: %v", i, err) + } + } + f.joinSlow(t, "", tok) + start := time.Now() + read := 3 + var err error + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _, _, err = slow.ws.Read(ctx) + cancel() + if err != nil { + break + } + read++ + time.Sleep(10 * time.Millisecond) + } + if websocket.CloseStatus(err) != websocket.StatusPolicyViolation { + t.Fatalf("the slow page ended with %v, want the relay's policy close", err) + } + if read >= n { + t.Fatalf("the slow page read all %d entries before its close: the replay was not stopped", read) + } + if took := time.Since(start); took > 10*time.Second { + t.Fatalf("the close took %v to arrive", took) + } +} + +// TestAReplayHasATimeBound: a replay that has not finished within its bound +// ends the connection, however slowly the page keeps reading. +func TestAReplayHasATimeBound(t *testing.T) { + const n = 60 + f := slowLogFixture(t, func(c *Config) { c.ReplayWithin = 300 * time.Millisecond }) + host, _ := f.hostSession(t) + code := bigLog(t, host, n, 250<<10) + slow, _ := f.joinSlow(t, code, "") + start := time.Now() + read := 0 + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _, _, err := slow.ws.Read(ctx) + cancel() + if err != nil { + break + } + read++ + time.Sleep(50 * time.Millisecond) + } + if read >= n { + t.Fatalf("the slow page read all %d entries: the replay had no bound", read) + } + if took := time.Since(start); took > 10*time.Second { + t.Fatalf("the bounded replay took %v to end", took) + } + deadline := time.Now().Add(3 * time.Second) + for f.srv.livePeers() > 1 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if live, counted := f.srv.livePeers(), f.srv.Relay().Stats().Connections; live != 1 || counted != 1 { + t.Fatalf("after the bounded replay: %d sockets served, %d counted; want the host alone", live, counted) + } +} diff --git a/internal/surface/session/server_test.go b/internal/surface/session/server_test.go new file mode 100644 index 000000000..fbc02840b --- /dev/null +++ b/internal/surface/session/server_test.go @@ -0,0 +1,996 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + core "github.com/intentdriven/abcd/internal/core/session" +) + +// testSecret is the sign-in app secret the tests configure. It is a test +// value, never a real one. +const testSecret = "test-client-secret-0123456789abcdef" + +// fakeGitHub is GitHub's API and sign-in endpoint as the relay sees them. +type fakeGitHub struct { + mu sync.Mutex + exchanges []url.Values + // users counts the requests GET /user saw, by token. + users map[string]int + // deviceStarts are the device-code requests; devicePolls the polls of + // the token endpoint with a device code, each answered by the next of + // deviceAnswers (the last repeats). + deviceStarts []url.Values + devicePolls []url.Values + deviceAnswers []map[string]any + deviceStart map[string]any // the device-code answer, when set +} + +func (g *fakeGitHub) handler() http.Handler { + logins := map[string]string{"ghu_ada": "ada", "ghu_bea": "bea", "ghu_cy": "cy"} + push := map[string]bool{"ada": true, "bea": true} + mux := http.NewServeMux() + mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + g.mu.Lock() + if g.users == nil { + g.users = map[string]int{} + } + g.users[token]++ + g.mu.Unlock() + login, ok := logins[token] + if !ok { + http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"login": login}) + }) + mux.HandleFunc("GET /repos/{owner}/{name}", func(w http.ResponseWriter, r *http.Request) { + login := logins[strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")] + if r.PathValue("owner") != "acme" || r.PathValue("name") != "brief" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"full_name": "acme/brief", "permissions": map[string]bool{"pull": true, "push": push[login]}}) + }) + mux.HandleFunc("POST /login/device/code", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + g.mu.Lock() + g.deviceStarts = append(g.deviceStarts, r.PostForm) + answer := g.deviceStart + g.mu.Unlock() + if answer == nil { + answer = map[string]any{ + "device_code": deviceCode, "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", "expires_in": 900, "interval": 5, + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(answer) + }) + mux.HandleFunc("POST /login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.PostForm.Get("grant_type") == "urn:ietf:params:oauth:grant-type:device_code" { + g.mu.Lock() + g.devicePolls = append(g.devicePolls, r.PostForm) + var answer map[string]any + if n := len(g.deviceAnswers); n > 0 { + answer = g.deviceAnswers[0] + if n > 1 { + g.deviceAnswers = g.deviceAnswers[1:] + } + } + g.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(answer) + return + } + g.mu.Lock() + g.exchanges = append(g.exchanges, r.PostForm) + g.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if r.PostForm.Get("code") != "good-code" { + // A hostile or careless upstream echoing the secret back. + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "bad_verification_code", + "error_description": "the code is wrong; client_secret=" + r.PostForm.Get("client_secret"), + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "ghu_session_token", "token_type": "bearer", "scope": ""}) + }) + return mux +} + +type fixture struct { + srv *Server + http *httptest.Server + github *fakeGitHub + wsURL string +} + +func newFixture(t *testing.T, mod func(*Config)) *fixture { + t.Helper() + gh := &fakeGitHub{} + ghSrv := httptest.NewServer(gh.handler()) + t.Cleanup(ghSrv.Close) + cfg := Config{ + ClientID: "Iv1.testclientid", + ClientSecret: testSecret, + GitHubAPI: ghSrv.URL, + GitHubWeb: ghSrv.URL, + HTTPClient: ghSrv.Client(), + Repos: []string{"acme/brief"}, + } + if mod != nil { + mod(&cfg) + } + srv, err := New(cfg) + if err != nil { + t.Fatal(err) + } + hs := httptest.NewServer(srv) + t.Cleanup(hs.Close) + return &fixture{srv: srv, http: hs, github: gh, wsURL: "ws" + strings.TrimPrefix(hs.URL, "http") + "/session"} +} + +// client is one browser's connection as a test drives it. +type client struct { + t *testing.T + ws *websocket.Conn +} + +func (f *fixture) dial(t *testing.T, origin string) *client { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ws, _, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {origin}}}) + if err != nil { + t.Fatalf("dial from %q: %v", origin, err) + } + t.Cleanup(func() { _ = ws.CloseNow() }) + return &client{t: t, ws: ws} +} + +// dialFrom dials as Tailscale Serve would proxy a visitor: with the visitor's +// tailnet address in X-Forwarded-For. +func (f *fixture) dialFrom(t *testing.T, origin, forwarded string) *client { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + h := http.Header{"Origin": {origin}, "X-Forwarded-For": {forwarded}} + ws, _, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: h}) + if err != nil { + t.Fatalf("dial from %q as %s: %v", origin, forwarded, err) + } + t.Cleanup(func() { _ = ws.CloseNow() }) + return &client{t: t, ws: ws} +} + +func (c *client) sendJSON(v any) { + c.t.Helper() + data, _ := json.Marshal(v) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.ws.Write(ctx, websocket.MessageText, data); err != nil { + c.t.Fatalf("write: %v", err) + } +} + +func (c *client) sendBinary(b []byte) { + c.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.ws.Write(ctx, websocket.MessageBinary, b); err != nil { + c.t.Fatalf("write: %v", err) + } +} + +// next reads one message; text messages decode to a map, binary come back raw. +func (c *client) next() (map[string]any, []byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + typ, data, err := c.ws.Read(ctx) + if err != nil { + return nil, nil, err + } + if typ == websocket.MessageBinary { + return nil, data, nil + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + c.t.Fatalf("text frame is not JSON: %q", data) + } + return m, nil, nil +} + +// until reads until a text message of type typ arrives, returning it and the +// binary frames read on the way. +func (c *client) until(typ string) (map[string]any, [][]byte) { + c.t.Helper() + var bins [][]byte + for { + m, b, err := c.next() + if err != nil { + c.t.Fatalf("waiting for %s: %v", typ, err) + } + if b != nil { + bins = append(bins, b) + continue + } + if m["type"] == typ { + return m, bins + } + } +} + +const origin = "https://abcdesign.app" + +// hostNumber is the host's participant number from its hosted answer: the +// last byte of the four an attributed update carries, in a test's small +// relay. +func hostNumber(t *testing.T, hosted map[string]any) byte { + t.Helper() + id, _ := hosted["id"].(string) + if len(id) != 2 || id[0] != 'p' || id[1] < '1' || id[1] > '9' { + t.Fatalf("hosted id %q is not a small participant id", id) + } + return id[1] - '0' +} + +func TestHostAndGuestOverTheWire(t *testing.T) { + f := newFixture(t, nil) + host := f.dial(t, origin) + host.sendJSON(map[string]string{"type": "verify", "token": "ghu_ada", "repo": "acme/brief"}) + if v, _ := host.until("verified"); v["member"] != true { + t.Fatalf("verified = %v", v) + } + host.sendJSON(map[string]string{"type": "host", "repo": "acme/brief"}) + hosted, _ := host.until("hosted") + code := hosted["code"].(string) + host.sendBinary([]byte{core.TagUpdate, 1, 2, 3}) + + guest := f.dial(t, "http://localhost:5173") + guest.sendJSON(map[string]string{"type": "join", "code": code, "name": "Gus"}) + if j, _ := guest.until("joined"); j["role"] != "guest" { + t.Fatalf("joined = %v", j) + } + _, replay := guest.until("synced") + hostNum := hostNumber(t, hosted) + if len(replay) != 1 || !bytes.Equal(replay[0], append([]byte{core.TagAttributedUpdate, 0, 0, 0, hostNum}, 1, 2, 3)) { + t.Fatalf("replay = %v, want the host's update", replay) + } + + guest.sendBinary([]byte{core.TagUpdate, 9}) + if e, _ := guest.until("error"); e["code"] != "read-only" { + t.Fatalf("a guest's update: %v, want read-only", e) + } + host.sendBinary([]byte{core.TagUpdate, 4}) + for { + _, b, err := guest.next() + if err != nil { + t.Fatal(err) + } + if b != nil { + if !bytes.Equal(b, []byte{core.TagAttributedUpdate, 0, 0, 0, hostNum, 4}) { + t.Fatalf("the guest received %v, want the host's second update", b) + } + break + } + } + + guest.sendJSON(map[string]any{"type": "suggest", "body": map[string]string{"text": "x"}}) + if s, _ := host.until("suggest"); s["from"] != "Gus" { + t.Fatalf("suggest = %v", s) + } + + host.sendJSON(map[string]string{"type": "close"}) + // Each page reads its close, so each socket ends at once; a socket + // stays counted until it has. + for _, c := range []*client{guest, host} { + c.until("closed") + if _, _, err := c.next(); websocket.CloseStatus(err) != websocket.StatusNormalClosure { + t.Fatalf("after close a page read %v, want a normal closure", err) + } + } + deadline := time.Now().Add(2 * time.Second) + for f.srv.Relay().Stats() != (core.Stats{}) { + if time.Now().After(deadline) { + t.Fatalf("the relay still holds %+v", f.srv.Relay().Stats()) + } + time.Sleep(10 * time.Millisecond) + } +} + +// TestDisallowedOriginIsRefused: by default the relay admits +// https://abcdesign.app and loopback pages; given origins replace that list, +// so then only they are admitted. +func TestDisallowedOriginIsRefused(t *testing.T) { + refused := func(f *fixture, o string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + h := http.Header{} + if o != "" { + h.Set("Origin", o) + } + _, resp, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: h}) + if err == nil || resp == nil || resp.StatusCode != http.StatusForbidden { + t.Errorf("origin %q: err=%v resp=%v, want 403", o, err, resp) + } + } + def := newFixture(t, nil) + for _, o := range []string{"https://abcdesign.app", "http://127.0.0.1:8000", "http://[::1]:3000", "http://localhost"} { + def.dial(t, o) + } + for _, o := range []string{"https://brief.example.org", "https://evil.example", "", "null"} { + refused(def, o) + } + f := newFixture(t, func(c *Config) { c.Origins = []string{"https://brief.example.org", "http://localhost:5173"} }) + for _, o := range []string{"https://abcdesign.app", "http://127.0.0.1:8000", "http://localhost", "http://localhost:5174"} { + refused(f, o) + } + for _, o := range []string{"https://brief.example.org", "http://localhost:5173"} { + f.dial(t, o) + } + for _, o := range []string{"https://evil.example", "", "null", "https://abcdesign.app.evil.example", "http://abcdesign.app"} { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + h := http.Header{} + if o != "" { + h.Set("Origin", o) + } + _, resp, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: h}) + cancel() + if err == nil || resp == nil || resp.StatusCode != http.StatusForbidden { + t.Errorf("origin %q: err=%v resp=%v, want 403", o, err, resp) + } + } +} + +func TestOversizedFrameEndsTheConnection(t *testing.T) { + f := newFixture(t, func(c *Config) { c.Limits.MaxMessageBytes = 1024 }) + c := f.dial(t, origin) + c.sendBinary(make([]byte, 2048)) + _, _, err := c.next() + if websocket.CloseStatus(err) != websocket.StatusMessageTooBig { + t.Fatalf("read after an oversized frame: %v, want close 1009", err) + } +} + +func post(t *testing.T, url, origin, body string) (*http.Response, string) { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if origin != "" { + req.Header.Set("Origin", origin) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp, string(data) +} + +const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + +func TestAuthTokenExchangesWithoutEverEchoingTheSecret(t *testing.T) { + f := newFixture(t, nil) + url := f.http.URL + "/auth/token" + + own := `,"redirect_uri":"` + f.http.URL + `/auth/callback"` + resp, body := post(t, url, origin, `{"code":"good-code","code_verifier":"`+verifier+`"`+own+`}`) + if resp.StatusCode != http.StatusOK || !strings.Contains(body, `"access_token":"ghu_session_token"`) { + t.Fatalf("exchange: %d %s", resp.StatusCode, body) + } + if resp.Header.Get("Access-Control-Allow-Origin") != origin { + t.Fatalf("CORS origin = %q", resp.Header.Get("Access-Control-Allow-Origin")) + } + f.github.mu.Lock() + got := f.github.exchanges[0] + f.github.mu.Unlock() + if got.Get("client_secret") != testSecret || got.Get("client_id") != "Iv1.testclientid" || got.Get("code_verifier") != verifier { + t.Fatalf("GitHub received %v, want the secret added and the verifier passed through", got) + } + + // GitHub's error description echoing the secret never reaches the browser. + resp, body = post(t, url, origin, `{"code":"bad-code","code_verifier":"`+verifier+`"`+own+`}`) + if resp.StatusCode != http.StatusBadRequest || strings.Contains(body, testSecret) || strings.Contains(body, "client_secret") { + t.Fatalf("failed exchange: %d %s", resp.StatusCode, body) + } + if !strings.Contains(body, "bad_verification_code") { + t.Fatalf("failed exchange body %s, want GitHub's error code", body) + } + + for _, c := range []struct{ origin, body string }{ + {"https://evil.example", `{"code":"good-code","code_verifier":"` + verifier + `"}`}, + {"", `{"code":"good-code","code_verifier":"` + verifier + `"}`}, + {origin, `{"code":"good-code","code_verifier":"short"}`}, + {origin, `{"code":"good-code","code_verifier":"` + verifier + `","redirect_uri":"https://evil.example/cb"}`}, + {origin, `not json`}, + } { + resp, body := post(t, url, c.origin, c.body) + if resp.StatusCode < 400 || strings.Contains(body, testSecret) || strings.Contains(body, "ghu_session") { + t.Errorf("origin %q body %s: %d %s, want a refusal", c.origin, c.body, resp.StatusCode, body) + } + } + + // The configuration names the public client id and never the secret. + cfgResp, err := http.Get(f.http.URL + "/config") + if err != nil { + t.Fatal(err) + } + cfgBody, _ := io.ReadAll(cfgResp.Body) + cfgResp.Body.Close() + if !strings.Contains(string(cfgBody), "Iv1.testclientid") || strings.Contains(string(cfgBody), testSecret) { + t.Fatalf("/config = %s", cfgBody) + } +} + +func TestAuthTokenWithoutASecretIsNotConfigured(t *testing.T) { + f := newFixture(t, func(c *Config) { c.ClientSecret = "" }) + resp, body := post(t, f.http.URL+"/auth/token", origin, `{"code":"good-code","code_verifier":"`+verifier+`"}`) + if resp.StatusCode != http.StatusServiceUnavailable || !strings.Contains(body, "sign-in-not-configured") { + t.Fatalf("%d %s", resp.StatusCode, body) + } + if n := len(f.github.exchanges); n != 0 { + t.Fatalf("GitHub was asked %d times", n) + } +} + +func TestAuthTokenPreflight(t *testing.T) { + f := newFixture(t, nil) + req, _ := http.NewRequest(http.MethodOptions, f.http.URL+"/auth/token", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "content-type") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent || resp.Header.Get("Access-Control-Allow-Origin") != origin || + !strings.Contains(resp.Header.Get("Access-Control-Allow-Methods"), "POST") { + t.Fatalf("preflight: %d %v", resp.StatusCode, resp.Header) + } +} + +func TestHealthz(t *testing.T) { + f := newFixture(t, nil) + resp, err := http.Get(f.http.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz = %d", resp.StatusCode) + } + resp, err = http.Get(f.http.URL + "/anything-else") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("an unknown path = %d, want 404", resp.StatusCode) + } +} + +func TestGitHubVerifier(t *testing.T) { + gh := httptest.NewServer((&fakeGitHub{}).handler()) + defer gh.Close() + v := GitHub{API: gh.URL, Client: gh.Client()} + ctx := context.Background() + for _, c := range []struct { + token, repo, login string + push, err bool + }{ + {"ghu_ada", "acme/brief", "ada", true, false}, + {"ghu_cy", "acme/brief", "cy", false, false}, + {"ghu_ada", "acme/other", "ada", false, false}, // GitHub's 404: no access + {"ghu_nobody", "acme/brief", "", false, true}, + } { + login, push, err := v.Verify(ctx, c.token, c.repo) + if login != c.login || push != c.push || (err != nil) != c.err { + t.Errorf("%s on %s: login=%q push=%v err=%v", c.token, c.repo, login, push, err) + } + if err != nil && strings.Contains(err.Error(), c.token) { + t.Errorf("the error carries the token: %v", err) + } + } + if _, _, err := v.Verify(ctx, "ghu_ada", "../../user"); err == nil { + t.Error("a repository that is not owner/name reached a request") + } +} + +func TestClientAddressTrustsForwardedOnlyBehindAProxy(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/session", nil) + r.RemoteAddr = "192.0.2.1:5555" + r.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.4") + if got := clientAddress(r, false, false); got != "192.0.2.1" { + t.Errorf("not behind a proxy: %q, want the socket's address", got) + } + if got := clientAddress(r, true, false); got != "198.51.100.4" { + t.Errorf("behind a proxy: %q, want the entry the proxy added", got) + } + r.Header.Del("X-Forwarded-For") + if got := clientAddress(r, true, false); got != "192.0.2.1" { + t.Errorf("behind a proxy with no header: %q, want the socket's address", got) + } +} + +// TestClientAddressOnTheTailnetRoute: on the host's own machine the relay +// listens on loopback and Tailscale Serve proxies to it, so every visitor's +// socket address is loopback. Only then is the address Serve forwards the one +// counted; a header from anywhere else, or one Serve would not send, is not. +func TestClientAddressOnTheTailnetRoute(t *testing.T) { + at := func(remote string, xff ...string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/session", nil) + r.RemoteAddr = remote + for _, v := range xff { + r.Header.Add("X-Forwarded-For", v) + } + return r + } + for _, c := range []struct { + name string + r *http.Request + loopback bool + want string + }{ + {"tailnet IPv4 through Serve", at("127.0.0.1:5555", "100.101.102.103"), true, "100.101.102.103"}, // abcd-lint:allow: a tailnet address, the shape under test + {"tailnet IPv6 through Serve", at("[::1]:5555", "fd7a:115c:a1e0::a1"), true, "fd7a:115c:a1e0::a1"}, // abcd-lint:allow: a tailnet address, the shape under test + {"a forged header from a non-loopback peer", at("192.0.2.7:5555", "100.64.0.1"), true, "192.0.2.7"}, // abcd-lint:allow: a tailnet address, the shape under test + {"a relay not bound to loopback alone", at("127.0.0.1:5555", "100.64.0.1"), false, "127.0.0.1"}, // abcd-lint:allow: a tailnet address, the shape under test + {"no header", at("127.0.0.1:5555"), true, "127.0.0.1"}, + {"an empty header", at("127.0.0.1:5555", ""), true, "127.0.0.1"}, + {"not an address", at("127.0.0.1:5555", "not-an-ip"), true, "127.0.0.1"}, + {"an address with a port", at("127.0.0.1:5555", "100.64.0.1:443"), true, "127.0.0.1"}, // abcd-lint:allow: a tailnet address, the shape under test + {"a list Serve never sends", at("127.0.0.1:5555", "100.64.0.1, 100.64.0.2"), true, "127.0.0.1"}, // abcd-lint:allow: a tailnet address, the shape under test + {"two header lines", at("127.0.0.1:5555", "100.64.0.1", "100.64.0.2"), true, "127.0.0.1"}, // abcd-lint:allow: a tailnet address, the shape under test + {"an address outside the tailnet", at("127.0.0.1:5555", "203.0.113.9"), true, "127.0.0.1"}, + } { + if got := clientAddress(c.r, false, c.loopback); got != c.want { + t.Errorf("%s: %q, want %q", c.name, got, c.want) + } + } +} + +// TestTailnetVisitorsAreLimitedSeparately: one tailnet visitor's wrong codes +// shut out that visitor alone, not everyone Serve proxies. +func TestTailnetVisitorsAreLimitedSeparately(t *testing.T) { + f := newFixture(t, func(c *Config) { + c.LoopbackOnly = true + c.Limits.MaxFailedJoins = 2 + }) + host := f.dial(t, origin) + host.sendJSON(map[string]string{"type": "verify", "token": "ghu_ada", "repo": "acme/brief"}) + host.until("verified") + host.sendJSON(map[string]string{"type": "host", "repo": "acme/brief"}) + hosted, _ := host.until("hosted") + code := hosted["code"].(string) + + for _, pair := range [][2]string{{"100.101.102.103", "100.64.0.9"}, {"fd7a:115c:a1e0::a1", "fd7a:115c:a1e0::b2"}} { // abcd-lint:allow: a tailnet address, the shape under test + mal := f.dialFrom(t, origin, pair[0]) + for i := 0; i < 2; i++ { + mal.sendJSON(map[string]string{"type": "join", "code": "ZZZZZZ", "name": "Mal"}) + if e, _ := mal.until("error"); e["code"] != "bad-code" { + t.Fatalf("%s try %d: %v, want bad-code", pair[0], i, e) + } + } + again := f.dialFrom(t, origin, pair[0]) + again.sendJSON(map[string]string{"type": "join", "code": code, "name": "Mal"}) + if e, _ := again.until("error"); e["code"] != "too-many-tries" { + t.Fatalf("%s past its limit: %v, want too-many-tries", pair[0], e) + } + other := f.dialFrom(t, origin, pair[1]) + other.sendJSON(map[string]string{"type": "join", "code": code, "name": "Gus"}) + if j, _ := other.until("joined"); j["role"] != "guest" { + t.Fatalf("%s was held up by %s's wrong codes: %v", pair[1], pair[0], j) + } + } +} + +func TestOriginsAreParsedStrictly(t *testing.T) { + for _, o := range []string{"https://ok.example", "http://ok.example:8080", "https://ok.example/", "https://ok.example/abcdesign/", "https://ok.example/a/b"} { + if _, err := New(Config{Origins: []string{o}, LoopbackOnly: true}); err != nil { + t.Errorf("%s refused: %v", o, err) + } + } + for _, o := range []string{"*", "ok.example", "ftp://ok.example", "https://*.example", "https://ok.example/../", + "https://ok.example/a/../b/", "https://ok.example/a%20b/", "https://ok.example//", "https://ok.example/a?x=1", "https://ok.example/a#f"} { + if _, err := New(Config{Origins: []string{o}, LoopbackOnly: true}); err == nil { + t.Errorf("%s accepted", o) + } + } +} + +// TestAdmittedOriginsDropTheDefaultPort: a browser's Origin never carries +// the scheme's default port, so an entry naming it admits the origin a +// browser sends, and the sign-in's callback forwards to its page. +func TestAdmittedOriginsDropTheDefaultPort(t *testing.T) { + f := newFixture(t, func(c *Config) { + c.Origins = []string{"https://Pages.Example:443/abcdesign/", "http://local.example:80", "https://odd.example:8443"} + }) + for o, want := range map[string]bool{ + "https://pages.example": true, "http://local.example": true, "https://odd.example:8443": true, + "https://pages.example:443": true, "https://odd.example": false, + } { + if got := f.srv.originAllowed(o); got != want { + t.Errorf("Origin %s admitted=%v, want %v", o, got, want) + } + } + resp, body := callback(t, f, url.Values{"code": {"good-code"}, "state": {signInState("https://pages.example/abcdesign/acme/brief/brief/")}}) + if resp.StatusCode != http.StatusSeeOther { + t.Errorf("a page on an entry naming port 443: %d %s, want it sent on", resp.StatusCode, body) + } +} + +// hostSession verifies a fresh connection as ada and opens a session, +// returning the connection and its join code. +func (f *fixture) hostSession(t *testing.T) (*client, string) { + t.Helper() + host := f.dial(t, origin) + host.sendJSON(map[string]string{"type": "verify", "token": "ghu_ada", "repo": "acme/brief"}) + host.until("verified") + host.sendJSON(map[string]string{"type": "host", "repo": "acme/brief"}) + hosted, _ := host.until("hosted") + return host, hosted["code"].(string) +} + +// TestReplayLongerThanTheSendQueue: a log longer than a connection's send +// queue reaches a newcomer whole, followed by synced, and does not drop it. +func TestReplayLongerThanTheSendQueue(t *testing.T) { + f := newFixture(t, func(c *Config) { + c.Limits.UpdatesPerSecond = 1 << 20 + c.Limits.UpdateBurst = 1 << 20 + }) + host, _ := f.hostSession(t) + n := sendQueue + 300 + for i := 0; i < n; i++ { + host.sendBinary([]byte{core.TagUpdate, byte(i), byte(i >> 8)}) + } + // The rotation's answer comes after every update was handled. + host.sendJSON(map[string]string{"type": "rotate"}) + got, _ := host.until("code") + code := got["code"].(string) + + guest := f.dial(t, origin) + guest.sendJSON(map[string]string{"type": "join", "code": code, "name": "Gus"}) + guest.until("joined") + _, replay := guest.until("synced") + if len(replay) != n { + t.Fatalf("the newcomer received %d updates before synced, want all %d", len(replay), n) + } + for i, b := range replay { + body := b[len(b)-2:] + if int(body[0])|int(body[1])<<8 != i&0xffff { + t.Fatalf("update %d arrived out of order: %v", i, b) + } + } +} + +// dialStatus tries the WebSocket upgrade and returns the HTTP status of a +// refusal, or 0 when the upgrade succeeded. +func (f *fixture) dialStatus(t *testing.T) int { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ws, resp, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {origin}}}) + if err == nil { + t.Cleanup(func() { _ = ws.CloseNow() }) + return 0 + } + if resp == nil { + t.Fatalf("dial: %v", err) + } + return resp.StatusCode +} + +// TestConnectionCapsRefuseTheUpgrade: past the relay-wide cap or the +// per-address cap, the upgrade is refused with 503, and the connections +// already held carry on. +func TestConnectionCapsRefuseTheUpgrade(t *testing.T) { + for _, c := range []struct { + name string + mod func(*Config) + }{ + {"relay-wide", func(c *Config) { c.Limits.MaxConnections = 2 }}, + {"per address", func(c *Config) { c.Limits.MaxConnsPerAddr = 2 }}, + } { + t.Run(c.name, func(t *testing.T) { + f := newFixture(t, c.mod) + host, code := f.hostSession(t) + guest := f.dial(t, origin) + if got := f.dialStatus(t); got != http.StatusServiceUnavailable { + t.Fatalf("a third connection: status %d, want 503", got) + } + guest.sendJSON(map[string]string{"type": "join", "code": code, "name": "Gus"}) + guest.until("joined") + host.sendJSON(map[string]string{"type": "rotate"}) + host.until("code") + if s := f.srv.Relay().Stats(); s.Connections != 2 { + t.Fatalf("connections = %d, want the two held", s.Connections) + } + }) + } +} + +// TestAConnectionThatJoinsNothingIsClosed: a connection that has not hosted, +// joined or rejoined within the grace period is closed normally; one that +// has stays open past it. +func TestAConnectionThatJoinsNothingIsClosed(t *testing.T) { + f := newFixture(t, func(c *Config) { c.Limits.AdmitWithin = 200 * time.Millisecond }) + host, _ := f.hostSession(t) + idle := f.dial(t, origin) + if e, _ := idle.until("error"); e["code"] != "join-timeout" { + t.Fatalf("idle connection: %v, want join-timeout", e) + } + if _, _, err := idle.next(); websocket.CloseStatus(err) != websocket.StatusNormalClosure { + t.Fatalf("idle connection ended with %v, want a normal closure", err) + } + time.Sleep(300 * time.Millisecond) + host.sendJSON(map[string]string{"type": "rotate"}) + host.until("code") +} + +// TestTheSendQueueGrowsOnEnteringASession: a connection outside a session +// may have few frames waiting; one inside, many. +func TestTheSendQueueGrowsOnEnteringASession(t *testing.T) { + p := newPeer(func() {}, preRoomQueue, 1<<30) + for i := 0; i < preRoomQueue; i++ { + if !p.Send(core.Frame{Data: []byte("{}")}) { + t.Fatalf("frame %d refused below the pre-session bound", i) + } + } + if p.Send(core.Frame{Data: []byte("{}")}) { + t.Fatalf("frame %d accepted past the pre-session bound", preRoomQueue) + } + // An overflow drops the queue and ends the connection, so the session's + // bound is counted on a peer of its own. + p = newPeer(func() {}, preRoomQueue, 1<<30) + p.Entered() + for i := 0; i < sendQueue; i++ { + if !p.Send(core.Frame{Data: []byte("{}")}) { + t.Fatalf("frame %d refused below the session bound", i) + } + } + if p.Send(core.Frame{Data: []byte("{}")}) { + t.Fatalf("frame %d accepted past the session bound", sendQueue) + } + if preRoomQueue > 64 { + t.Fatalf("preRoomQueue = %d, want it small", preRoomQueue) + } +} + +// TestARelayBeyondLoopbackNeedsItsRepositories: a relay not bound to +// loopback alone serves only the repositories it is given, and refuses to +// start without any; a malformed entry is refused too. +func TestARelayBeyondLoopbackNeedsItsRepositories(t *testing.T) { + if _, err := New(Config{}); err == nil { + t.Fatal("a relay beyond loopback started with no repositories") + } + if _, err := New(Config{LoopbackOnly: true}); err != nil { + t.Fatalf("a loopback relay with no list: %v", err) + } + for _, c := range []Config{ + {Repos: []string{"acme/brief"}}, + {Owners: []string{"acme"}}, + } { + if _, err := New(c); err != nil { + t.Errorf("%+v refused: %v", c, err) + } + } + for _, c := range []Config{ + {Repos: []string{"acme"}}, + {Repos: []string{"acme/brief/x"}}, + {Owners: []string{"acme/brief"}}, + {Owners: []string{"-acme"}}, + {Repos: []string{"acme/brief"}, Owners: []string{""}}, + } { + if _, err := New(c); err == nil { + t.Errorf("%+v accepted", c) + } + } +} + +// TestVerifyAgainstAnUnservedRepositoryIsRefused: over the wire, a token +// presented for a repository the relay does not serve is refused before +// GitHub is asked. +func TestVerifyAgainstAnUnservedRepositoryIsRefused(t *testing.T) { + f := newFixture(t, nil) + c := f.dial(t, origin) + c.sendJSON(map[string]string{"type": "verify", "token": "ghu_ada", "repo": "mal/own"}) + if e, _ := c.until("error"); e["code"] != "repo-not-served" { + t.Fatalf("verify: %v, want repo-not-served", e) + } + c.sendJSON(map[string]string{"type": "host", "repo": "mal/own"}) + if e, _ := c.until("error"); e["code"] != "repo-not-served" { + t.Fatalf("host: %v, want repo-not-served", e) + } +} + +// TestConfigNamesAGitHubAppOnly: sign-in goes through a GitHub App alone, so +// /config reports github-app whenever a client id is set, its authorize URL +// carries no scope, and without a client id it names no app type at all +// (security review 2, item 3). +func TestConfigNamesAGitHubAppOnly(t *testing.T) { + get := func(f *fixture) map[string]any { + resp, err := http.Get(f.http.URL + "/config") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var m map[string]any + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + t.Fatal(err) + } + return m + } + m := get(newFixture(t, nil)) + if m["app_type"] != "github-app" || m["sign_in"] != true { + t.Fatalf("/config = %v, want a GitHub App's sign-in", m) + } + if u, _ := m["authorize_url"].(string); strings.Contains(u, "scope") || strings.Contains(u, "?") { + t.Fatalf("authorize_url = %q, want no scope", u) + } + m = get(newFixture(t, func(c *Config) { c.ClientID, c.ClientSecret = "", "" })) + if _, named := m["app_type"]; named || m["sign_in"] != false { + t.Fatalf("/config without a client id = %v, want no app type and no sign-in", m) + } +} + +// TestAuthTokenIsLimitedPerAddress: the sign-in exchange is counted per +// address, as wrong codes are; past ten in ten minutes it is refused with 429 +// before GitHub is asked (security review 2, item 10). +func TestAuthTokenIsLimitedPerAddress(t *testing.T) { + f := newFixture(t, nil) + url := f.http.URL + "/auth/token" + body := `{"code":"good-code","code_verifier":"` + verifier + `","redirect_uri":"` + f.http.URL + `/auth/callback"}` + for i := 0; i < 10; i++ { + if resp, got := post(t, url, origin, body); resp.StatusCode != http.StatusOK { + t.Fatalf("exchange %d: %d %s", i+1, resp.StatusCode, got) + } + } + resp, got := post(t, url, origin, body) + if resp.StatusCode != http.StatusTooManyRequests || !strings.Contains(got, "rate-limited") || strings.Contains(got, "ghu_session") { + t.Fatalf("the eleventh exchange: %d %s, want 429 rate-limited", resp.StatusCode, got) + } + if resp.Header.Get("Retry-After") == "" { + t.Error("the refusal does not say when to try again") + } + f.github.mu.Lock() + n := len(f.github.exchanges) + f.github.mu.Unlock() + if n != 10 { + t.Fatalf("GitHub was asked %d times, want 10", n) + } +} + +// TestTheSendQueueIsBoundedInBytes: a connection's queue holds at most its +// byte cap as well as its count of items. Past it the frame is refused, and +// the queue is dropped at once and the connection ended, so a peer that +// stops reading holds nothing more. A log replay is not charged: its entries +// are the session's own (security review 2, item 1). +func TestTheSendQueueIsBoundedInBytes(t *testing.T) { + ended := false + p := newPeer(func() { ended = true }, sendQueue, 1000) + p.Entered() + if !p.Send(core.Frame{Data: make([]byte, 600)}) { + t.Fatal("a frame inside the byte cap was refused") + } + if !p.SendLog([][]byte{make([]byte, 5000)}) { + t.Fatal("a log replay was charged its entries' bytes") + } + if p.Send(core.Frame{Data: make([]byte, 401)}) { + t.Fatal("a frame past the byte cap was queued") + } + if !ended || p.queuedBytes() != 0 { + t.Fatalf("past the cap: ended=%v, %d bytes still queued", ended, p.queuedBytes()) + } +} + +// TestANonReadingPeerStaysBounded reproduces the review's proof of concept: +// a guest sends awareness frames as fast as it can to a host whose page reads +// nothing. Before the fix, 256 KiB frames and one such host held 292 MiB in +// eight seconds. Now a frame over 8 KiB is refused outright, and with the +// frame and rate limits lifted to let the flood through, the host's queue +// never holds more than its byte cap and the host is dropped. +func TestANonReadingPeerStaysBounded(t *testing.T) { + big := append([]byte{core.TagAwareness}, make([]byte, 256<<10-1)...) + t.Run("refused at its size", func(t *testing.T) { + f := newFixture(t, nil) + _, code := f.hostSession(t) + guest := f.dial(t, origin) + guest.sendJSON(map[string]string{"type": "join", "code": code, "name": "Mal"}) + guest.until("synced") + guest.sendBinary(big) + if e, _ := guest.until("error"); e["code"] != "too-big" { + t.Fatalf("a 256 KiB awareness frame: %v, want too-big", e) + } + if q := f.srv.queuedBytes(); q > 64<<10 { + t.Fatalf("%d bytes queued after a refused frame", q) + } + }) + t.Run("the queue's byte cap", func(t *testing.T) { + f := newFixture(t, func(c *Config) { + c.Limits.MaxAwarenessBytes = 256 << 10 + c.Limits.AwarenessPerSecond, c.Limits.AwarenessBurst = 1<<20, 1<<20 + c.Limits.AwarenessBytesPerSecond, c.Limits.AwarenessBytesBurst = 1<<30, 1<<30 + c.Limits.AwarenessConnBytesPerSecond, c.Limits.AwarenessConnBytesBurst = 1<<30, 1<<30 + }) + _, code := f.hostSession(t) // the host reads nothing from here on + guest := f.dial(t, origin) + guest.sendJSON(map[string]string{"type": "join", "code": code, "name": "Mal"}) + guest.until("synced") + limit := f.srv.Relay().Limits().MaxQueuedBytes + if limit != 8<<20 { + t.Fatalf("MaxQueuedBytes = %d, want 8 MiB", limit) + } + peak := 0 + dropped := func() bool { return f.srv.Relay().Stats().Participants == 1 } + for i := 0; i < 400 && !dropped(); i++ { // up to 100 MiB + guest.sendBinary(big) + if q := f.srv.queuedBytes(); q > peak { + peak = q + } + } + for deadline := time.Now().Add(5 * time.Second); !dropped() && time.Now().Before(deadline); { + time.Sleep(10 * time.Millisecond) + } + if peak > limit { + t.Fatalf("the relay queued %d bytes for one peer, over its cap of %d", peak, limit) + } + if !dropped() { + t.Fatalf("the host that reads nothing is still in the session (peak %d bytes)", peak) + } + if q := f.srv.queuedBytes(); q > 64<<10 { + t.Fatalf("%d bytes still queued after the host was dropped", q) + } + }) +} + +// TestClientAddressBehindRendersProxy: Render's proxy appends the address +// it saw to X-Forwarded-For and keeps whatever the client sent before it +// (https://render.com/articles/host-pocketbase-on-render), and Render's own +// advice is to trust one proxy hop (https://render.com/articles/how-render-handles-ddos-attacks). +// So behind it the client is the rightmost entry, never the leftmost; an +// entry that is not an address reads as missing. +func TestClientAddressBehindRendersProxy(t *testing.T) { + at := func(xff ...string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/session", nil) + r.RemoteAddr = "192.0.2.1:5555" + for _, v := range xff { + r.Header.Add("X-Forwarded-For", v) + } + return r + } + for _, c := range []struct { + name string + r *http.Request + want string + }{ + {"the proxy's entry alone", at("203.0.113.9"), "203.0.113.9"}, + {"a forged entry before the proxy's", at("192.0.2.77, 198.51.100.4, 203.0.113.9"), "203.0.113.9"}, + {"a forged line before the proxy's", at("192.0.2.77", "203.0.113.9"), "203.0.113.9"}, + {"IPv6", at("192.0.2.66, 2001:db8::7"), "2001:db8::7"}, + {"a mapped address", at("::ffff:203.0.113.9"), "203.0.113.9"}, + {"not an address", at("192.0.2.66, nonsense"), "192.0.2.1"}, + {"an address with a port", at("203.0.113.9:443"), "192.0.2.1"}, + {"a trailing comma", at("203.0.113.9, "), "192.0.2.1"}, + } { + if got := clientAddress(c.r, true, false); got != c.want { + t.Errorf("%s: %q, want %q", c.name, got, c.want) + } + } +} diff --git a/internal/surface/session/signin_test.go b/internal/surface/session/signin_test.go new file mode 100644 index 000000000..f5b2f9e39 --- /dev/null +++ b/internal/surface/session/signin_test.go @@ -0,0 +1,449 @@ +package session + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// deviceCode is the device code the fake GitHub hands out: it must never +// reach a browser. +const deviceCode = "3584d83530557fdd1f46af8289938c8ef79f9dc5" + +// clock is a settable time for the relay's device sign-in. +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) add(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +// deviceFixture is a relay set up for a local session: it serves its own +// page and holds the GitHub App's client id and no secret, so it offers the +// device flow. +func deviceFixture(t *testing.T, mod func(*Config)) (*fixture, *clock) { + t.Helper() + clk := &clock{now: time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)} + f := newFixture(t, func(c *Config) { + c.ClientSecret = "" + c.ServePage = true + c.Now = clk.Now + if mod != nil { + mod(c) + } + }) + return f, clk +} + +func decode(t *testing.T, body string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatalf("not JSON: %v: %s", err, body) + } + return m +} + +// TestVerifyTakesOnlyAGitHubAppUserToken: nobody pastes a token. The relay +// takes only a GitHub App user access token, whose prefix GitHub documents as +// ghu_, and refuses any other before GitHub is asked (adr-2610161400542916). +func TestVerifyTakesOnlyAGitHubAppUserToken(t *testing.T) { + gh := &fakeGitHub{} + ghSrv := httptest.NewServer(gh.handler()) + t.Cleanup(ghSrv.Close) + srv := newFixture(t, nil) + v := GitHub{API: ghSrv.URL, Client: ghSrv.Client()} + for _, token := range []string{"ghp_personal", "github_pat_11AAAA", "gho_oauthapp", "ghs_installation", "ada"} { + if _, _, err := v.Verify(context.Background(), token, "acme/brief"); err == nil { + t.Errorf("%s was accepted", token) + } + } + gh.mu.Lock() + asked := len(gh.users) + gh.mu.Unlock() + if asked != 0 { + t.Fatalf("GitHub was asked about %d tokens that are not a GitHub App's", asked) + } + login, push, err := v.Verify(context.Background(), "ghu_ada", "acme/brief") + if err != nil || login != "ada" || !push { + t.Fatalf("a GitHub App user token: %q %v %v", login, push, err) + } + + // Over the wire, too: a pasted personal access token is refused. + c := srv.dial(t, origin) + c.sendJSON(map[string]string{"type": "verify", "token": "ghp_personal", "repo": "acme/brief"}) + if e, _ := c.until("error"); e["code"] != "verify-failed" { + t.Fatalf("verify with a personal access token: %v, want verify-failed", e) + } +} + +// TestConfigNamesTheFlow: a relay with the app's secret offers the web flow, +// a local session (its own page, the client id alone) the device flow, and +// one with neither nothing. A relay that serves no page offers no device +// flow: without the secret it offers no sign-in at all. +func TestConfigNamesTheFlow(t *testing.T) { + get := func(f *fixture) map[string]any { + resp, err := http.Get(f.http.URL + "/config") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return decode(t, string(data)) + } + if m := get(newFixture(t, nil)); m["flow"] != "web" || m["sign_in"] != true { + t.Errorf("with a secret: %v, want the web flow", m) + } + f, _ := deviceFixture(t, nil) + if m := get(f); m["flow"] != "device" || m["sign_in"] != true || m["app_type"] != "github-app" { + t.Errorf("with a client id alone: %v, want the device flow", m) + } + if m := get(newFixture(t, func(c *Config) { c.ClientID, c.ClientSecret = "", "" })); m["sign_in"] != false { + t.Errorf("with neither: %v, want no sign-in", m) + } + cloud := newFixture(t, func(c *Config) { c.ClientSecret = "" }) + if m := get(cloud); m["sign_in"] != false || m["flow"] == "device" { + t.Errorf("a relay serving no page, with no secret: %v, want sign-in off and no device flow", m) + } + if resp, body := post(t, cloud.http.URL+"/auth/device", origin, `{}`); resp.StatusCode != http.StatusServiceUnavailable || len(cloud.github.deviceStarts) != 0 { + t.Errorf("a relay serving no page started a device sign-in: %d %s", resp.StatusCode, body) + } +} + +// TestDeviceSignInKeepsTheDeviceCodeOnTheRelay walks a device sign-in: the +// relay asks GitHub for a device code and keeps it, the page gets a handle +// and the user code, and the relay polls GitHub no faster than its interval, +// slowing on slow_down, until the person confirms. +func TestDeviceSignInKeepsTheDeviceCodeOnTheRelay(t *testing.T) { + f, clk := deviceFixture(t, nil) + f.github.deviceAnswers = []map[string]any{ + {"error": "authorization_pending"}, + {"error": "slow_down", "interval": 10}, + {"access_token": "ghu_ada", "token_type": "bearer", "scope": ""}, + } + resp, body := post(t, f.http.URL+"/auth/device", origin, `{}`) + if resp.StatusCode != http.StatusOK || strings.Contains(body, deviceCode) { + t.Fatalf("start: %d %s, want a handle and never the device code", resp.StatusCode, body) + } + if resp.Header.Get("Access-Control-Allow-Origin") != origin { + t.Fatalf("CORS origin = %q", resp.Header.Get("Access-Control-Allow-Origin")) + } + m := decode(t, body) + handle, _ := m["handle"].(string) + if len(handle) != 22 || m["user_code"] != "WDJB-MJHT" || m["verification_uri"] != f.srv.cfg.GitHubWeb+"/login/device" { + t.Fatalf("start = %v", m) + } + f.github.mu.Lock() + start := f.github.deviceStarts[0] + f.github.mu.Unlock() + if start.Get("client_id") != "Iv1.testclientid" || start.Has("client_secret") { + t.Fatalf("GitHub received %v, want the client id and no secret", start) + } + + poll := func() map[string]any { + t.Helper() + _, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"`+handle+`"}`) + return decode(t, body) + } + polls := func() int { + f.github.mu.Lock() + defer f.github.mu.Unlock() + return len(f.github.devicePolls) + } + // Too soon: the relay answers pending without asking GitHub. + if got := poll(); got["error"] != "authorization_pending" || polls() != 0 { + t.Fatalf("an early poll: %v, %d asked; want pending and GitHub not asked", got, polls()) + } + clk.add(5 * time.Second) + if got := poll(); got["error"] != "authorization_pending" || polls() != 1 { + t.Fatalf("first poll: %v (%d asked)", got, polls()) + } + f.github.mu.Lock() + sent := f.github.devicePolls[0] + f.github.mu.Unlock() + if sent.Get("device_code") != deviceCode || sent.Get("client_id") != "Iv1.testclientid" || sent.Has("client_secret") { + t.Fatalf("the poll sent %v", sent) + } + clk.add(5 * time.Second) + if got := poll(); got["error"] != "slow_down" || polls() != 2 { + t.Fatalf("second poll: %v (%d asked)", got, polls()) + } + // Slowed to GitHub's new interval of ten seconds. + clk.add(5 * time.Second) + if got := poll(); got["error"] != "authorization_pending" || polls() != 2 { + t.Fatalf("a poll inside the slowed interval: %v (%d asked)", got, polls()) + } + clk.add(5 * time.Second) + if got := poll(); got["access_token"] != "ghu_ada" || polls() != 3 { + t.Fatalf("the confirmed poll: %v (%d asked)", got, polls()) + } + // Done: the handle is gone. + clk.add(10 * time.Second) + if got := poll(); got["error"] != "expired_token" || polls() != 3 { + t.Fatalf("a poll after the token: %v (%d asked)", got, polls()) + } +} + +// TestDeviceSignInRefusals: the device routes check the page's origin, are +// offered only by a relay without a secret, count against the per-address +// exchange limit, refuse an unknown handle, and drop a sign-in GitHub ends. +func TestDeviceSignInRefusals(t *testing.T) { + f, clk := deviceFixture(t, nil) + if resp, body := post(t, f.http.URL+"/auth/device", "https://evil.example", `{}`); resp.StatusCode != http.StatusForbidden || strings.Contains(body, "handle") { + t.Errorf("a foreign origin: %d %s", resp.StatusCode, body) + } + if resp, body := post(t, f.http.URL+"/auth/device/token", "https://evil.example", `{"handle":"AAAAAAAAAAAAAAAAAAAAAA"}`); resp.StatusCode != http.StatusForbidden { + t.Errorf("a foreign origin's poll: %d %s", resp.StatusCode, body) + } + if resp, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"AAAAAAAAAAAAAAAAAAAAAA"}`); resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "expired_token") { + t.Errorf("an unknown handle: %d %s", resp.StatusCode, body) + } + if resp, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"../x"}`); resp.StatusCode != http.StatusBadRequest { + t.Errorf("a malformed handle: %d %s", resp.StatusCode, body) + } + web := newFixture(t, nil) + if resp, body := post(t, web.http.URL+"/auth/device", origin, `{}`); resp.StatusCode != http.StatusServiceUnavailable || len(web.github.deviceStarts) != 0 { + t.Errorf("a relay with a secret: %d %s, want the device flow not offered", resp.StatusCode, body) + } + + // GitHub ending the sign-in drops it. + f.github.deviceAnswers = []map[string]any{{"error": "access_denied"}} + _, body := post(t, f.http.URL+"/auth/device", origin, `{}`) + handle, _ := decode(t, body)["handle"].(string) + clk.add(5 * time.Second) + if resp, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"`+handle+`"}`); resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "access_denied") { + t.Errorf("a denied sign-in: %d %s", resp.StatusCode, body) + } + if _, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"`+handle+`"}`); !strings.Contains(body, "expired_token") { + t.Errorf("the denied sign-in was kept: %s", body) + } + + // A device code that expires is dropped without asking GitHub. + f.github.deviceAnswers = []map[string]any{{"error": "authorization_pending"}} + _, body = post(t, f.http.URL+"/auth/device", origin, `{}`) + handle, _ = decode(t, body)["handle"].(string) + clk.add(16 * time.Minute) + f.github.mu.Lock() + before := len(f.github.devicePolls) + f.github.mu.Unlock() + if _, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"`+handle+`"}`); !strings.Contains(body, "expired_token") { + t.Errorf("an expired device code: %s", body) + } + f.github.mu.Lock() + after := len(f.github.devicePolls) + f.github.mu.Unlock() + if after != before { + t.Error("GitHub was asked about an expired device code") + } + + // Starting a device sign-in counts against the exchange limit, ten in + // ten minutes per address: the starts above are past the window now. + // Each is ended (denied) before the next, so the cap on sign-ins one + // address holds at once is not what refuses. + f.github.deviceAnswers = []map[string]any{{"error": "access_denied"}} + for i := 0; i < 10; i++ { + resp, body := post(t, f.http.URL+"/auth/device", origin, `{}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("start %d: %d %s", i+1, resp.StatusCode, body) + } + handle, _ := decode(t, body)["handle"].(string) + clk.add(5 * time.Second) + if _, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"`+handle+`"}`); !strings.Contains(body, "access_denied") { + t.Fatalf("ending start %d: %s", i+1, body) + } + } + if resp, body := post(t, f.http.URL+"/auth/device", origin, `{}`); resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" { + t.Errorf("the eleventh start: %d %s, want 429", resp.StatusCode, body) + } +} + +// TestDeviceSignInsAreBounded: at most maxDeviceLogins wait at once, from +// however many addresses. +func TestDeviceSignInsAreBounded(t *testing.T) { + f, _ := deviceFixture(t, func(c *Config) { c.BehindProxy = true }) + start := func(addr string) *http.Response { + req, _ := http.NewRequest(http.MethodPost, f.http.URL+"/auth/device", strings.NewReader(`{}`)) + req.Header.Set("Origin", origin) + req.Header.Set("X-Forwarded-For", addr) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp + } + for i := 0; i < maxDeviceLogins; i++ { + if resp := start("198.51.100." + itoa(i%200+1)); resp.StatusCode != http.StatusOK { + t.Fatalf("start %d: %d", i+1, resp.StatusCode) + } + } + if resp := start("203.0.113.9"); resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("one past the bound: %d, want 503", resp.StatusCode) + } +} + +// TestDeviceSignInsAreBoundedPerAddress: one address holds at most +// maxDeviceLoginsPerAddr sign-ins at once, fewer than the exchange limit lets +// it start, so a few addresses cannot take every place maxDeviceLogins +// allows. Another address is not held up, and a place is freed when a +// sign-in ends. +func TestDeviceSignInsAreBoundedPerAddress(t *testing.T) { + if maxDeviceLoginsPerAddr >= 10 || maxDeviceLoginsPerAddr*4 > maxDeviceLogins { + t.Fatalf("maxDeviceLoginsPerAddr = %d: want it under the exchange limit and well under maxDeviceLogins", maxDeviceLoginsPerAddr) + } + f, clk := deviceFixture(t, func(c *Config) { c.BehindProxy = true }) + start := func(addr string) (*http.Response, string) { + req, _ := http.NewRequest(http.MethodPost, f.http.URL+"/auth/device", strings.NewReader(`{}`)) + req.Header.Set("Origin", origin) + req.Header.Set("X-Forwarded-For", addr) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp, string(data) + } + var first string + for i := 0; i < maxDeviceLoginsPerAddr; i++ { + resp, body := start("198.51.100.7") + if resp.StatusCode != http.StatusOK { + t.Fatalf("start %d: %d %s", i+1, resp.StatusCode, body) + } + if i == 0 { + first, _ = decode(t, body)["handle"].(string) + } + } + f.github.mu.Lock() + asked := len(f.github.deviceStarts) + f.github.mu.Unlock() + if resp, body := start("198.51.100.7"); resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" { + t.Fatalf("one past the address's bound: %d %s, want 429", resp.StatusCode, body) + } + f.github.mu.Lock() + after := len(f.github.deviceStarts) + f.github.mu.Unlock() + if after != asked { + t.Error("GitHub was asked for a device code past the address's bound") + } + if resp, body := start("203.0.113.9"); resp.StatusCode != http.StatusOK { + t.Fatalf("another address: %d %s", resp.StatusCode, body) + } + // Ending one frees its place. + f.github.deviceAnswers = []map[string]any{{"error": "access_denied"}} + clk.add(5 * time.Second) + req, _ := http.NewRequest(http.MethodPost, f.http.URL+"/auth/device/token", strings.NewReader(`{"handle":"`+first+`"}`)) + req.Header.Set("Origin", origin) + req.Header.Set("X-Forwarded-For", "198.51.100.7") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp, body := start("198.51.100.7"); resp.StatusCode != http.StatusOK { + t.Fatalf("after one ended: %d %s", resp.StatusCode, body) + } +} + +func itoa(i int) string { + b, _ := json.Marshal(i) + return string(b) +} + +// TestADeviceSignInThePageStopsIsEnded: the page ends a device sign-in the +// person stops, and the relay frees its place at once rather than holding it +// until GitHub's code expires. +func TestADeviceSignInThePageStopsIsEnded(t *testing.T) { + f, _ := deviceFixture(t, func(c *Config) { c.BehindProxy = true }) + start := func() (*http.Response, string) { + req, _ := http.NewRequest(http.MethodPost, f.http.URL+"/auth/device", strings.NewReader(`{}`)) + req.Header.Set("Origin", origin) + req.Header.Set("X-Forwarded-For", "198.51.100.7") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp, string(data) + } + var handles []string + for i := 0; i < maxDeviceLoginsPerAddr; i++ { + resp, body := start() + if resp.StatusCode != http.StatusOK { + t.Fatalf("start %d: %d %s", i+1, resp.StatusCode, body) + } + h, _ := decode(t, body)["handle"].(string) + handles = append(handles, h) + } + if resp, _ := start(); resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("one past the address's bound: %d, want 429", resp.StatusCode) + } + // From another origin, the stop is refused and the place stays held. + if resp, _ := post(t, f.http.URL+"/auth/device/cancel", "https://evil.example", `{"handle":"`+handles[0]+`"}`); resp.StatusCode != http.StatusForbidden { + t.Fatalf("a stop from another origin: %d, want 403", resp.StatusCode) + } + if resp, body := post(t, f.http.URL+"/auth/device/cancel", origin, `{"handle":"`+handles[0]+`"}`); resp.StatusCode != http.StatusOK { + t.Fatalf("stop: %d %s", resp.StatusCode, body) + } + resp, body := post(t, f.http.URL+"/auth/device/token", origin, `{"handle":"`+handles[0]+`"}`) + if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "expired_token") { + t.Errorf("a stopped sign-in still answers: %d %s", resp.StatusCode, body) + } + if resp, body := start(); resp.StatusCode != http.StatusOK { + t.Fatalf("after a stop: %d %s, want the place freed", resp.StatusCode, body) + } + if resp, _ := post(t, f.http.URL+"/auth/device/cancel", origin, `{"handle":"bad"}`); resp.StatusCode != http.StatusBadRequest { + t.Errorf("a malformed handle: %d, want 400", resp.StatusCode) + } +} + +// TestTheDeviceHoldSaysHowLongItLasts: an address refused for holding its +// share of device sign-ins is told to come back when its first one ends, not +// after the exchange window. +func TestTheDeviceHoldSaysHowLongItLasts(t *testing.T) { + f, clk := deviceFixture(t, func(c *Config) { c.BehindProxy = true }) + start := func() *http.Response { + req, _ := http.NewRequest(http.MethodPost, f.http.URL+"/auth/device", strings.NewReader(`{}`)) + req.Header.Set("Origin", origin) + req.Header.Set("X-Forwarded-For", "198.51.100.7") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp + } + for i := 0; i < maxDeviceLoginsPerAddr; i++ { + if resp := start(); resp.StatusCode != http.StatusOK { + t.Fatalf("start %d: %d", i+1, resp.StatusCode) + } + clk.add(time.Minute) + } + resp := start() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("one past the bound: %d", resp.StatusCode) + } + // The first started maxDeviceLoginsPerAddr minutes ago and lives 900 s. + want := strconv.Itoa(900 - 60*maxDeviceLoginsPerAddr) + if got := resp.Header.Get("Retry-After"); got != want { + t.Errorf("Retry-After = %q, want %s, when the first sign-in ends", got, want) + } +} diff --git a/internal/surface/session/web/design_test.go b/internal/surface/session/web/design_test.go new file mode 100644 index 000000000..c0f831730 --- /dev/null +++ b/internal/surface/session/web/design_test.go @@ -0,0 +1,187 @@ +package web + +import ( + "regexp" + "strings" + "testing" +) + +// The page has two designs the team can try against each other: Guided, the +// default, and Shared. Ctrl+Shift+[ and Ctrl+Shift+] (Ctrl+{ and Ctrl+}) switch +// between them on every platform, the Mac included (never Cmd), and each +// viewer's choice is remembered in this browser alone. Plain Ctrl+[ and +// Ctrl+] stay the editor's indent and outdent. Both designs toggle their sidebars with +// Alt+Shift+D and Alt+Shift+S, and Escape closes the overlay on top. + +// designHarness is the page's design and shortcut functions, with storage, +// the toast and the layout stubbed. +func designHarness(t *testing.T) string { + js := pageScript(t) + var b strings.Builder + b.WriteString(` +const stored = new Map(); +let storageThrows = false; +globalThis.localStorage = { + getItem: (k) => { if (storageThrows) throw new Error("blocked"); return stored.has(k) ? stored.get(k) : null; }, + setItem: (k, v) => { if (storageThrows) throw new Error("blocked"); stored.set(k, String(v)); }, + removeItem: (k) => stored.delete(k), +}; +const WORDS = new Proxy({}, { get: (_, k) => String(k) }); +const toasts = []; +function toast(text) { toasts.push(text); } +let laidOut = 0; +function applyLayout() { laidOut++; } +let design = ""; +`) + for _, c := range []string{"DESIGNS", "KEY_DESIGN"} { + b.WriteString(constLine(t, js, c) + "\n") + } + for _, fn := range []string{"recall", "remember", "storedDesign", "switchDesign", "shortcut"} { + b.WriteString(functionBody(t, js, fn) + "\n") + } + return b.String() +} + +func TestThePageRemembersEachViewersDesignAndDefaultsToGuided(t *testing.T) { + script := designHarness(t) + ` +const out = {}; +design = storedDesign(); +out.first = design; +switchDesign(1); +out.next = design; +out.stored = stored.get(KEY_DESIGN); +design = storedDesign(); +out.reloaded = design; +switchDesign(-1); +out.back = design; +stored.set(KEY_DESIGN, ""); +out.garbage = storedDesign(); +storageThrows = true; +out.blocked = storedDesign(); +switchDesign(1); +out.switchedBlocked = design; +out.toasts = toasts; +out.laidOut = laidOut; +console.log(JSON.stringify(out)); +` + var out struct { + First, Next, Stored, Reloaded, Back, Garbage, Blocked, SwitchedBlocked string + Toasts []string + LaidOut int + } + runPage(t, script, &out) + if out.First != "guided" { + t.Errorf("with nothing stored the page opens on %q, want guided", out.First) + } + if out.Next != "shared" || out.Stored != "shared" || out.Reloaded != "shared" { + t.Errorf("switching: now %q, stored %q, after a reload %q; want shared each time", out.Next, out.Stored, out.Reloaded) + } + if out.Back != "guided" { + t.Errorf("switching back gave %q, want guided", out.Back) + } + if out.Garbage != "guided" || out.Blocked != "guided" { + t.Errorf("an unknown stored design gave %q and blocked storage %q; want guided for both", out.Garbage, out.Blocked) + } + if out.SwitchedBlocked != "shared" { + t.Errorf("with storage blocked the switch gave %q, want shared for this page view", out.SwitchedBlocked) + } + if len(out.Toasts) != 3 || out.Toasts[0] != "designShared" || out.Toasts[1] != "designGuided" || out.Toasts[2] != "designShared" { + t.Errorf("toasts %v, want each switch to name the design it switched to", out.Toasts) + } + if out.LaidOut != 3 { + t.Errorf("the layout was applied %d times for 3 switches", out.LaidOut) + } + js := pageScript(t) + for _, w := range []string{"designGuided", "designShared"} { + if !regexp.MustCompile(`(?m)^ ` + w + `: "[^"]+",$`).MatchString(js) { + t.Errorf("the page has no words for %s", w) + } + } +} + +func TestTheShortcutsAreCtrlShiftBracketsOnEveryPlatformAndAltShiftLetters(t *testing.T) { + script := designHarness(t) + ` +const k = (o) => shortcut(Object.assign({ ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, key: "", code: "" }, o)); +console.log(JSON.stringify({ + ctrlShiftLeft: k({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{" }), + ctrlShiftRight: k({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }), + ctrlLeft: k({ ctrlKey: true, code: "BracketLeft", key: "[" }), + ctrlRight: k({ ctrlKey: true, code: "BracketRight", key: "]" }), + ctrlShiftKeyOnly: k({ ctrlKey: true, shiftKey: true, code: "Digit8", key: "{" }), + cmdShiftRight: k({ metaKey: true, shiftKey: true, code: "BracketRight", key: "}" }), + ctrlCmd: k({ ctrlKey: true, metaKey: true, shiftKey: true, code: "BracketLeft", key: "{" }), + ctrlAlt: k({ ctrlKey: true, altKey: true, shiftKey: true, code: "BracketLeft", key: "{" }), + altGraph: k({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{", getModifierState: (m) => m === "AltGraph" }), + altLeft: k({ altKey: true, code: "BracketLeft", key: "\u201c" }), + docs: k({ altKey: true, shiftKey: true, code: "KeyD", key: "Î" }), + side: k({ altKey: true, shiftKey: true, code: "KeyS", key: "Í" }), + docsNoShift: k({ altKey: true, code: "KeyD", key: "∂" }), + docsCtrl: k({ altKey: true, shiftKey: true, ctrlKey: true, code: "KeyD", key: "D" }), + escape: k({ key: "Escape", code: "Escape" }), + plain: k({ key: "d", code: "KeyD" }), +})); +` + var out map[string]string + runPage(t, script, &out) + want := map[string]string{ + "ctrlShiftLeft": "design-prev", "ctrlShiftRight": "design-next", + "ctrlLeft": "", "ctrlRight": "", "ctrlShiftKeyOnly": "", + "cmdShiftRight": "", "ctrlCmd": "", "ctrlAlt": "", "altGraph": "", "altLeft": "", + "docs": "left", "side": "right", "docsNoShift": "", "docsCtrl": "", + "escape": "escape", "plain": "", + } + for name, w := range want { + if out[name] != w { + t.Errorf("%s: shortcut gave %q, want %q", name, out[name], w) + } + } + // The shortcuts are named the same on every platform, in the tooltips. + js := pageScript(t) + for _, w := range []string{`"Alt+Shift+D"`, `"Alt+Shift+S"`} { + if !strings.Contains(js, w) { + t.Errorf("session.js does not name the shortcut %s", w) + } + } + for _, bad := range []string{"⌥", "⌘", "navigator.platform"} { + if strings.Contains(js, bad) { + t.Errorf("session.js carries %q: the shortcuts are the same on every platform", bad) + } + } +} + +// Each design's colours are custom properties, and the cobalt accent is one +// token in each theme: no rule spells the accent's value out again. +func TestEachDesignTakesTheCobaltAccentAsOneToken(t *testing.T) { + raw, err := assets.ReadFile("session.css") + if err != nil { + t.Fatal(err) + } + css := strings.ToLower(string(raw)) + for _, want := range []string{ + `system-ui, -apple-system, "segoe ui", roboto, "noto sans", ubuntu, cantarell, sans-serif`, + `[data-design="guided"]`, `[data-design="shared"]`, + `@media (prefers-color-scheme: dark)`, `@media (prefers-reduced-motion: reduce)`, + `:focus-visible`, + } { + if !strings.Contains(css, want) { + t.Errorf("session.css does not carry %s", want) + } + } + for _, accent := range []string{"#2457d6", "#7aa2ff"} { + decl := regexp.MustCompile(`--accent:\s*` + accent + `;`) + if n := len(decl.FindAllString(css, -1)); n == 0 { + t.Errorf("no --accent token is %s", accent) + } + if strings.Count(css, accent) != len(decl.FindAllString(css, -1)) { + t.Errorf("%s is spelled out outside the --accent token", accent) + } + } + for _, m := range regexp.MustCompile(`--accent:\s*([^;]+);`).FindAllStringSubmatch(css, -1) { + if v := strings.TrimSpace(m[1]); v != "#2457d6" && v != "#7aa2ff" { + t.Errorf("--accent is set to %s; the accent is cobalt", v) + } + } + if !strings.Contains(css, `[data-design="shared"] .g-only`) || !strings.Contains(css, `[data-design="guided"] .s-only`) { + t.Error("session.css does not hide each design's own elements in the other") + } +} diff --git a/internal/surface/session/web/device_page_test.go b/internal/surface/session/web/device_page_test.go new file mode 100644 index 000000000..09f36c5fb --- /dev/null +++ b/internal/surface/session/web/device_page_test.go @@ -0,0 +1,100 @@ +package web + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// constLine is the named top-level const declaration of session.js. +func constLine(t *testing.T, js, name string) string { + t.Helper() + m := regexp.MustCompile(`(?m)^const ` + regexp.QuoteMeta(name) + ` = .*;$`).FindString(js) + if m == "" { + t.Fatalf("session.js has no const %s", name) + } + return m +} + +// runPage runs script under node and decodes the JSON it prints last. +func runPage(t *testing.T, script string, into any) { + t.Helper() + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + path := filepath.Join(t.TempDir(), "page.mjs") + if err := os.WriteFile(path, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + got, err := exec.Command(node, path).CombinedOutput() + if err != nil { + t.Fatalf("node: %v\n%s", err, got) + } + lines := strings.Split(strings.TrimSpace(string(got)), "\n") + if err := json.Unmarshal([]byte(lines[len(lines)-1]), into); err != nil { + t.Fatalf("node said %s", got) + } +} + +// A device sign-in the relay refuses for too many under way says so, and one +// the person stops is ended on the relay. +func TestADeviceSignInSaysWhenTooManyAreUnderWayAndEndsWhenStopped(t *testing.T) { + js := pageScript(t) + script := ` +const WORDS = new Proxy({}, { get: (_, k) => String(k) }); +const toasts = []; +function toast(t) { toasts.push(t); } +const requests = []; +let answer = { status: 429, body: { error: "rate-limited" } }; +globalThis.fetch = async (url, init) => { requests.push({ url, body: JSON.parse(init.body), keepalive: !!init.keepalive }); return { status: answer.status, json: async () => answer.body }; }; +let anchor = { state: "self" }; +const session = { relay: "http://127.0.0.1:4000" }; +const ui = { device: { hidden: true }, deviceCode: {}, deviceOpen: { focus() {} } }; +let device = null; +function pollDevice() {} +` + functionBody(t, js, "relayPost") + "\n" + functionBody(t, js, "startDeviceSignIn") + "\n" + functionBody(t, js, "stopDeviceSignIn") + ` +const out = {}; +(async () => { + await startDeviceSignIn(); + answer = { status: 503, body: { error: "busy" } }; + await startDeviceSignIn(); + out.toasts = toasts.slice(); + device = { handle: "h".repeat(22), relay: session.relay, timer: 0 }; + requests.length = 0; + stopDeviceSignIn(true); + out.stopped = requests.slice(); + device = { handle: "h".repeat(22), relay: session.relay, timer: 0 }; + requests.length = 0; + stopDeviceSignIn(); + out.ended = requests.length; + console.log(JSON.stringify(out)); +})(); +` + var out struct { + Toasts []string + Stopped []struct { + URL string + Body map[string]string + Keepalive bool + } + Ended int + } + runPage(t, script, &out) + if len(out.Toasts) != 2 || out.Toasts[0] != "signInBusy" || out.Toasts[1] != "signInBusy" { + t.Errorf("too many sign-ins under way: toasts %v, want signInBusy for each", out.Toasts) + } + if len(out.Stopped) != 1 || out.Stopped[0].URL != "http://127.0.0.1:4000/auth/device/cancel" || out.Stopped[0].Body["handle"] != strings.Repeat("h", 22) || !out.Stopped[0].Keepalive { + t.Errorf("a stopped sign-in: %+v, want the relay told to end it", out.Stopped) + } + if out.Ended != 0 { + t.Errorf("a sign-in that ended by itself was cancelled on the relay too (%d requests)", out.Ended) + } + if !strings.Contains(js, `signInBusy: "`) { + t.Error("the page has no words for too many sign-ins under way") + } +} diff --git a/internal/surface/session/web/index.html b/internal/surface/session/web/index.html new file mode 100644 index 000000000..fc29c068a --- /dev/null +++ b/internal/surface/session/web/index.html @@ -0,0 +1,20 @@ + + + + + + + + +Brief session + + + + + + +
      +

      This page opens a project's brief for a live session. Open it at /<owner>/<repo>/brief/.

      +
      + + diff --git a/internal/surface/session/web/layout_test.go b/internal/surface/session/web/layout_test.go new file mode 100644 index 000000000..91dd62ed8 --- /dev/null +++ b/internal/surface/session/web/layout_test.go @@ -0,0 +1,669 @@ +package web + +import ( + "regexp" + "strings" + "testing" +) + +// The page's layout, run for real: build() makes the page's elements in a +// small fake DOM, and the tests drive the sidebars, the overlays, the keys +// and the focus the way a person would. The fake DOM knows the stylesheet's +// two rules that decide whether an element can be seen at all: the hidden +// attribute, and each design hiding the other's .g-only or .s-only parts. + +// fakeDOM is a document just big enough for build() and the layout. +const fakeDOM = ` +class Node0 { + constructor() { this.parentNode = null; this.childNodes = []; } + get firstChild() { return this.childNodes[0] || null; } + contains(n) { for (let x = n; x; x = x.parentNode) if (x === this) return true; return false; } +} +class Text0 extends Node0 { + constructor(t) { super(); this.nodeType = 3; this.data = String(t); } + get textContent() { return this.data; } +} +function dataName(k) { return "data-" + String(k).replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); } +function blurIfInside(n) { if (n.contains(document.activeElement)) document.activeElement = document.body; } +function detach(n) { + const p = n.parentNode; + if (!p) return; + blurIfInside(n); + p.childNodes.splice(p.childNodes.indexOf(n), 1); + n.parentNode = null; +} +class El extends Node0 { + constructor(tag) { + super(); + this.nodeType = 1; + this.tagName = tag.toUpperCase(); + this.attrs = new Map(); + this.listeners = {}; + this.style = { setProperty() {}, removeProperty() {} }; + const self = this; + this.classList = { + contains: (c) => self.className.split(/\s+/).includes(c), + add: (c) => { if (!self.classList.contains(c)) self.className = (self.className + " " + c).trim(); }, + remove: (c) => { self.className = self.className.split(/\s+/).filter((x) => x && x !== c).join(" "); }, + toggle: (c, on) => { if (on === undefined) on = !self.classList.contains(c); if (on) self.classList.add(c); else self.classList.remove(c); return on; }, + }; + this.dataset = new Proxy({}, { + get: (_, k) => { const v = self.getAttribute(dataName(k)); return v === null ? undefined : v; }, + set: (_, k, v) => { self.setAttribute(dataName(k), String(v)); return true; }, + }); + } + get className() { return this.getAttribute("class") || ""; } + set className(v) { this.setAttribute("class", v); } + get id() { return this.getAttribute("id") || ""; } + get title() { return this.getAttribute("title") || ""; } + set title(v) { this.setAttribute("title", v); } + setAttribute(k, v) { this.attrs.set(k, String(v)); } + getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; } + removeAttribute(k) { this.attrs.delete(k); } + hasAttribute(k) { return this.attrs.has(k); } + get hidden() { return this.attrs.has("hidden"); } + set hidden(v) { if (v) this.attrs.set("hidden", ""); else this.attrs.delete("hidden"); } + append(...ns) { + for (let n of ns) { + if (typeof n === "string") n = new Text0(n); + detach(n); + n.parentNode = this; + this.childNodes.push(n); + } + } + removeChild(n) { detach(n); return n; } + get children() { return this.childNodes.filter((c) => c.nodeType === 1); } + get childElementCount() { return this.children.length; } + get textContent() { return this.childNodes.map((c) => c.textContent).join(""); } + set textContent(v) { while (this.childNodes.length) this.removeChild(this.childNodes[0]); if (v !== "" && v !== null && v !== undefined) this.append(String(v)); } + get isConnected() { return document.documentElement.contains(this); } + addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); } + click() { for (const f of this.listeners.click || []) f({ preventDefault() {}, target: this }); } + focus() { if (shownFake(this)) document.activeElement = this; } + blur() { if (document.activeElement === this) document.activeElement = document.body; } + getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }; } + getClientRects() { return [{}]; } + get scrollWidth() { return 0; } + get clientWidth() { return 0; } + all() { const out = []; const walk = (n) => { for (const c of n.children) { out.push(c); walk(c); } }; walk(this); return out; } +} +// shownFake says whether the stylesheet lets an element be seen: no hidden +// ancestor, and none of the other design's own parts. +function shownFake(node) { + if (!node || !node.isConnected) return false; + const other = document.documentElement.dataset.design === "shared" ? "g-only" : "s-only"; + for (let n = node; n && n.nodeType === 1; n = n.parentNode) { + if (n.hidden || n.classList.contains(other)) return false; + } + return true; +} +let width = 1440; +const html = new El("html"); +Object.defineProperty(html, "clientWidth", { get: () => width }); +const body = new El("body"); +const appRoot = new El("div"); +appRoot.setAttribute("id", "app"); +html.append(body); +body.append(appRoot); +globalThis.document = { + documentElement: html, body, activeElement: body, title: "", + createElement: (t) => new El(t), + createElementNS: (_, t) => new El(t), + createTextNode: (t) => new Text0(t), + getElementById: (id) => (id === "app" ? appRoot : null), +}; +const windowListeners = {}; +globalThis.window = { innerWidth: 0, addEventListener: (t, f) => { windowListeners[t] = f; } }; +// keydown sends a key press through the page's own window listener. +function keydown(o) { + let prevented = false; + const e = Object.assign({ key: "", code: "", ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, isComposing: false, keyCode: 0, + target: document.activeElement, getModifierState: () => false, + preventDefault() { prevented = true; }, stopPropagation() {} }, o); + windowListeners.keydown(e); + return prevented; +} +` + +// pageStubs is the rest of the page that the layout touches, held still. +const pageStubs = ` +const stored = new Map(); +globalThis.localStorage = { + getItem: (k) => (stored.has(k) ? stored.get(k) : null), + setItem: (k, v) => stored.set(k, String(v)), + removeItem: (k) => stored.delete(k), +}; +const toasts = []; +function toast(t) { toasts.push(t); } +const ICONS = {}; +const route = { owner: "acme", repo: "harbour", slug: "acme/harbour" }; +const session = { relay: "http://127.0.0.1:8790", code: "", name: "", login: "", role: "", myId: "p1", roster: [], + member: false, host: false, inRoom: false, synced: false, seeding: false, everJoined: false, over: false, pending: "" }; +let anchor = { state: "self", relays: [] }; +let signInOffered = true; +let linkCode = ""; +let heldToken = ""; +let trustPending = null; +let sending = false; +let design = "guided"; +let placedFor = ""; +let statusText = ""; +let statusBad = false; +let view = null; +let currentPath = ""; +const ui = {}; +const notes = []; +const mine = []; +const files = new Map(); +const suggestions = new Map(); +const suggestionsDone = new Map(); +const doc = { transact(f) { f(); } }; +const meta = { set() {} }; +function relayStanding() { return "self"; } +function standingRefusal(s) { return "refused:" + s; } +function pageURL() { return "https://abcdesign.app/acme/harbour/brief/"; } +function framed() { return false; } +function qrcode() { throw new Error("no QR code in the fake DOM"); } +function inviteURL() { return "https://abcdesign.app/acme/harbour/brief/#code=" + session.code; } +function effectiveMeta() { return {}; } +function validSuggestion() { return true; } +function sendJSON() {} +function renderSend() {} +function showSendResult() {} +function refreshSignIn() {} +function confirmRelay() {} +function startSignIn() {} +function openFile() {} +function firstFile() { return ""; } +// fakeView is an editor whose text area sits in the page's editor slot. +function fakeView() { + const dom = new El("div"); + dom.className = "cm-editor"; + const contentDOM = new El("div"); + contentDOM.className = "cm-content"; + dom.append(contentDOM); + ui.editor.append(dom); + return { dom, contentDOM, focus() { contentDOM.focus(); }, requestMeasure() {}, destroy() {}, + state: { selection: { main: { empty: false, from: 0, to: 4 } }, sliceDoc: () => "text" } }; +} +// live puts the page in a session: a member who hosts, or a guest. +function live(role) { + session.role = role; session.member = role === "member"; session.host = role === "member"; + session.inRoom = true; session.synced = true; session.everJoined = true; session.code = "ABC234"; + session.name = "Carol"; session.roster = [{ id: "p1", name: "Carol", role, host: session.host }]; + view = fakeView(); + renderAll(); +} +function setWidth(w) { width = w; applyLayout(); } +function state() { return ui.app.dataset.left + "/" + ui.app.dataset.right; } +function focused() { + const a = document.activeElement; + for (const [k, v] of Object.entries(ui)) if (v === a) return k; + if (view && a === view.contentDOM) return "editor"; + return a === document.body ? "body" : a.tagName + "." + a.className; +} +` + +// pageFunctions are the functions of session.js the harness runs for real. +var pageFunctions = []string{ + "el", "clear", "recall", "remember", "say", "icon", "avatar", "badge", "prettyName", "fileIcon", + "closeButton", "areaHead", "nextSteps", "ownerNotice", + "storedDesign", "switchDesign", "shortcut", "onKey", "viewWidth", "inWorkspace", "tier", + "closeTop", "defaultSheet", "toggleSide", "openSheet", "sideShown", "applyLayout", "placeParts", "fitTiles", + "hiddenNow", "focusFallback", "focusOn", "settleFocus", "noteOpener", "returnFocus", + "toggleInvite", "closeInvite", "submitSession", "closeSend", "askTrust", "trustAnswered", + "setStatus", "paintStatus", "build", "renderTop", "renderStart", "renderFiles", "renderGuestBar", "renderSide", + "openSuggestForm", "renderAll", +} + +// pageConsts are the constants of session.js the harness takes as they are. +var pageConsts = []string{ + "DESIGNS", "KEY_DESIGN", "PHONE_MAX", "SLIDE_MAX", "SVG_NS", "MAX_REPLACEMENT", "GITHUB_DEVICE", + "COLOURS", "BRIEF_DIR", "BRIEF_PREFIX", "layout", "openers", +} + +// livePage is the page built in the fake DOM. With realWords the page +// shows its own words; without, each word is its key, so a test can find +// an element by the word it shows. +func livePage(t *testing.T, realWords bool) string { + t.Helper() + js := pageScript(t) + var b strings.Builder + b.WriteString(fakeDOM) + if realWords { + b.WriteString(wordsBlock(t, js) + "\n});\n") + } else { + b.WriteString("const WORDS = new Proxy({}, { get: (_, k) => String(k) });\n") + } + b.WriteString(pageStubs) + for _, c := range pageConsts { + if m := regexp.MustCompile(`(?m)^const ` + c + ` = .*;$`).FindString(js); m != "" { + b.WriteString(m + "\n") + } + } + for _, fn := range pageFunctions { + // A function the page does not have yet is left out, so the test + // that needs it fails on what the page does, not on the harness. + if regexp.MustCompile(`(?m)^(?:async )?function ` + fn + `\(`).MatchString(js) { + b.WriteString(functionBody(t, js, fn) + "\n") + } + } + b.WriteString("build();\n") + return b.String() +} + +// Each sidebar keeps its state through every change of screen and design. +// A wide window and the slide-over widths share the documents' state; the +// right panel at the slide-over widths, and both sidebars on a phone, keep +// their own, so an Escape at one width never hides a panel at another. +func TestSidebarStateIsKeptPerWidthAndAcrossDesignSwitches(t *testing.T) { + script := livePage(t, false) + ` +const out = {}; +live("member"); +const altS = () => keydown({ altKey: true, shiftKey: true, code: "KeyS", key: "S" }); +const altD = () => keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D" }); +const esc = () => keydown({ key: "Escape", code: "Escape", target: document.body }); +setWidth(1440); +out.wide = state(); +altS(); +out.wideRightOff = state(); +setWidth(1100); +out.slide = state(); +out.slideEsc = esc(); +out.slideAfterEsc = state(); +setWidth(1440); +out.wideAgain = state(); +altS(); +out.wideRightOn = state(); +setWidth(1100); +out.slideStillClosed = state(); +setWidth(390); +out.phone = state(); +altD(); +out.phoneDocsOff = state(); +setWidth(1440); +out.wideAfterPhone = state(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }); +out.sharedWide = state(); +setWidth(1280); +out.sharedSlide = state(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{" }); +out.guided1280 = state(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }); +setWidth(390); +out.sharedPhone = state(); +altD(); +out.sharedPhoneDocs = state(); +altS(); +out.sharedPhoneSide = state(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{" }); +out.guidedPhone = state(); +console.log(JSON.stringify(out)); +` + var out map[string]any + runPage(t, script, &out) + want := map[string]string{ + "wide": "shown/shown", "wideRightOff": "shown/hidden", + "slide": "shown/shown", "slideAfterEsc": "shown/hidden", + "wideAgain": "shown/hidden", "wideRightOn": "shown/shown", "slideStillClosed": "shown/hidden", + "phone": "shown/hidden", "phoneDocsOff": "hidden/hidden", "wideAfterPhone": "shown/shown", + "sharedWide": "shown/shown", "sharedSlide": "shown/hidden", "guided1280": "shown/shown", + "sharedPhone": "hidden/hidden", "sharedPhoneDocs": "shown/hidden", "sharedPhoneSide": "hidden/shown", + "guidedPhone": "hidden/hidden", + } + for k, w := range want { + if out[k] != w { + t.Errorf("%s: sidebars %v, want %s", k, out[k], w) + } + } + if out["slideEsc"] != true { + t.Errorf("Escape at a slide-over width did not close the open slide-over") + } +} + +// The keys: each shortcut the page takes is its own (preventDefault), AltGr +// typing a character is never one, a composition's Escape and the editor's +// own Escape are left alone, and Escape closes only what can be seen. +func TestTheKeysAreThePagesOnlyWhenTheyAskForIt(t *testing.T) { + script := livePage(t, false) + ` +const out = {}; +setWidth(1440); +out.sideOutside = keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D" }); +out.sideOutsideState = state(); +out.design = keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }); +out.designNow = design; +out.altGr = keydown({ ctrlKey: false, altKey: false, shiftKey: true, code: "BracketLeft", key: "{", getModifierState: (m) => m === "AltGraph" }); +out.altGrBoth = keydown({ ctrlKey: true, altKey: true, shiftKey: true, code: "BracketLeft", key: "{" }); +// Plain Ctrl+[ and Ctrl+] are the editor's indent and outdent. +out.plainLeft = keydown({ ctrlKey: true, code: "BracketLeft", key: "[" }); +out.plainRight = keydown({ ctrlKey: true, code: "BracketRight", key: "]" }); +out.altGrCtrl = keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{", getModifierState: (m) => m === "AltGraph" }); +out.altGrSide = keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D", getModifierState: (m) => m === "AltGraph" }); +out.afterAltGr = design; +live("member"); +out.side = keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D" }); +out.sideState = state(); +out.altDBack = keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D" }); +// A slide-over is open: the editor's Escape and a composition's are theirs. +setWidth(1100); +view.contentDOM.focus(); +out.editorEsc = keydown({ key: "Escape", code: "Escape", target: view.contentDOM }); +out.composeEsc = keydown({ key: "Escape", code: "Escape", isComposing: true, target: document.body }); +out.ime229 = keydown({ key: "Escape", code: "Escape", keyCode: 229, target: document.body }); +out.stillOpen = state(); +out.pageEsc = keydown({ key: "Escape", code: "Escape", target: document.body }); +out.closed = state(); +out.nothingLeft = keydown({ key: "Escape", code: "Escape", target: document.body }); +// State that belongs to another design or width is not on top of anything. +setWidth(1440); +ui.inviteToggle.click(); +out.inviteOpen = ui.app.dataset.overlay; +keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{" }); +out.guidedOverlay = ui.app.dataset.overlay; +out.staleInvite = keydown({ key: "Escape", code: "Escape", target: document.body }); +setWidth(390); +openSheet("people"); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }); +out.staleSheet = keydown({ key: "Escape", code: "Escape", target: document.body }); +keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D" }); +out.sharedPhoneDocs = state(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{" }); +out.guidedSheetAgain = keydown({ key: "Escape", code: "Escape", target: document.body }); +out.stalePleft = keydown({ key: "Escape", code: "Escape", target: document.body }); +console.log(JSON.stringify(out)); +` + var out map[string]any + runPage(t, script, &out) + want := map[string]any{ + "sideOutside": false, "sideOutsideState": "shown/shown", + "design": true, "designNow": "shared", + "altGr": false, "altGrBoth": false, "plainLeft": false, "plainRight": false, "altGrCtrl": false, "altGrSide": false, "afterAltGr": "shared", + "side": true, "sideState": "hidden/shown", + "editorEsc": false, "composeEsc": false, "ime229": false, "stillOpen": "shown/shown", + "pageEsc": true, "closed": "shown/hidden", "nothingLeft": false, + "inviteOpen": "invite", "guidedOverlay": "", "staleInvite": false, + "staleSheet": false, "sharedPhoneDocs": "shown/hidden", "guidedSheetAgain": true, "stalePleft": false, + } + for k, w := range want { + if out[k] != w { + t.Errorf("%s: %v, want %v", k, out[k], w) + } + } +} + +// Escape closes the overlay on top, in order: the relay confirmation (which +// is refused), the Send panel, the invite card, the guest's suggestion, +// then a phone's sheet or sidebar, then a slide-over. +func TestEscapeClosesTheOverlayOnTop(t *testing.T) { + script := livePage(t, false) + ` +const steps = []; +const esc = () => keydown({ key: "Escape", code: "Escape", target: document.body }); +const snap = () => [ui.trust.hidden ? "" : "trust", ui.app.dataset.overlay, ui.suggestForm.hidden ? "" : "form", ui.app.dataset.sheet, state()].join(" "); +live("member"); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }); +setWidth(1100); +ui.inviteToggle.click(); +submitSession(); +askTrust("join"); +for (let i = 0; i < 5; i++) steps.push(esc() + " " + snap()); +setWidth(1440); +steps.push(esc() + " " + snap()); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{" }); +setWidth(390); +openSheet("people"); +steps.push(esc() + " " + snap()); +steps.push(esc() + " " + snap()); +console.log(JSON.stringify({ steps })); +` + var out struct{ Steps []string } + runPage(t, script, &out) + want := []string{ + "true send shown/shown", + "true invite shown/shown", + "true shown/shown", + "true shown/hidden", + "false shown/hidden", + "false shown/shown", + "true shown/hidden", + "false shown/hidden", + } + if len(out.Steps) != len(want) { + t.Fatalf("steps %q", out.Steps) + } + for i := range want { + if out.Steps[i] != want[i] { + t.Errorf("Escape %d: %q, want %q", i+1, out.Steps[i], want[i]) + } + } +} + +// Focus: the editor keeps it across a design switch, each dialog takes it +// when it opens and gives it back to what opened it, and a focused control +// a layout change hides hands it to the control that does its job now. +func TestFocusStaysWherePeopleCanSeeIt(t *testing.T) { + script := livePage(t, false) + ` +const out = {}; +const esc = () => keydown({ key: "Escape", code: "Escape", target: document.activeElement }); +setWidth(1440); +renderAll(); +ui.signInStart.focus(); +askTrust("signin"); +out.trustOpen = focused(); +esc(); +out.trustClosed = focused(); +live("member"); +view.focus(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}", target: view.contentDOM }); +out.editorShared = focused(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{", target: view.contentDOM }); +out.editorGuided = focused(); +ui.submitG.focus(); +ui.submitG.click(); +out.sendOpen = ui.send.contains(document.activeElement); +esc(); +out.sendClosed = focused(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }); +ui.submit.focus(); +ui.submit.click(); +out.sendOpenShared = ui.send.contains(document.activeElement); +[...ui.send.all()].find((b) => b.className === "close").click(); +out.sendClosedShared = focused(); +ui.inviteToggle.focus(); +ui.inviteToggle.click(); +out.inviteOpen = ui.sShare.contains(document.activeElement) && document.activeElement !== ui.sShare; +esc(); +out.inviteClosed = focused(); +ui.inviteToggle.click(); +[...ui.sShare.children].find((b) => b.className === "close").click(); +out.inviteClosedByX = focused(); +// A control the layout hides hands focus on. +ui.sLeft.focus(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketLeft", key: "{" }); +out.toggleAcross = focused(); +ui.gRight.focus(); +keydown({ ctrlKey: true, shiftKey: true, code: "BracketRight", key: "}" }); +out.rightAcross = focused(); +const tab = ui.files.children[0] || (() => { const b = new El("button"); ui.files.append(b); return b; })(); +tab.focus(); +out.inSidebar = ui.sSidebar.contains(document.activeElement); +keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D" }); +out.sidebarHidden = focused(); +console.log(JSON.stringify(out)); +` + var out map[string]any + runPage(t, script, &out) + want := map[string]any{ + "trustOpen": "trustCancel", "trustClosed": "signInStart", + "editorShared": "editor", "editorGuided": "editor", + "sendOpen": true, "sendClosed": "submitG", "sendOpenShared": true, "sendClosedShared": "submit", + "inviteOpen": true, "inviteClosed": "inviteToggle", "inviteClosedByX": "inviteToggle", + "toggleAcross": "gLeft", "rightAcross": "sRight", "inSidebar": true, "sidebarHidden": "sLeft", + } + for k, w := range want { + if out[k] != w { + t.Errorf("%s: %v, want %v", k, out[k], w) + } + } +} + +// The sidebar toggles say what they are and whether they are on: a stable +// name, and aria-pressed. The name never flips to Hide or Show. +func TestTheSidebarTogglesKeepTheirNames(t *testing.T) { + script := livePage(t, false) + ` +live("member"); +setWidth(1440); +const names = () => [ui.gLeft, ui.gRight, ui.sLeft, ui.sRight].map((b) => b.getAttribute("aria-label") + "=" + b.getAttribute("aria-pressed") + "|" + (b.title || b.dataset.tip)).join(" "); +const out = { before: names() }; +keydown({ altKey: true, shiftKey: true, code: "KeyD", key: "D" }); +keydown({ altKey: true, shiftKey: true, code: "KeyS", key: "S" }); +out.after = names(); +console.log(JSON.stringify(out)); +` + var out struct{ Before, After string } + runPage(t, script, &out) + wantBefore := "documents=true|documents (Alt+Shift+D) reviewPeople=true|reviewPeople (Alt+Shift+S) documents=true|documents sugShort=true|sugShort" + wantAfter := "documents=false|documents (Alt+Shift+D) reviewPeople=false|reviewPeople (Alt+Shift+S) documents=false|documents sugShort=false|sugShort" + if out.Before != wantBefore { + t.Errorf("before: %s\nwant %s", out.Before, wantBefore) + } + if out.After != wantAfter { + t.Errorf("after: %s\nwant %s", out.After, wantAfter) + } +} + +// "What happens next" is the guided design's alone. It shows on the start +// screen whenever the page can go on (or sessions are not set up yet), on +// the device sign-in, and, live, only for a guest; never in Shared. +func TestWhatHappensNextIsTheGuidedDesignsAlone(t *testing.T) { + script := livePage(t, false) + ` +const out = {}; +// count is how many "What happens next" headings show, in each design. +function count() { + const res = {}; + for (const d of ["guided", "shared"]) { + design = d; applyLayout(); + res[d] = ui.app.all().filter((n) => n.tagName === "H2" && n.textContent === "whatNext" && shownFake(n) + && (shownFake(ui.device) ? ui.device.contains(n) : true)).length; + } + design = "guided"; applyLayout(); + return res.guided + "/" + res.shared; +} +// Every one of them sits in a part only the guided design shows. +out.unmarked = ui.app.all().filter((n) => n.textContent === "whatNext" && n.tagName === "H2").filter((n) => { + for (let x = n; x && x.nodeType === 1; x = x.parentNode) if (x.classList.contains("g-only")) return false; + return true; +}).length; +renderAll(); +out.start = count(); +linkCode = "ABC234"; renderAll(); +out.invited = count(); +linkCode = ""; session.relay = ""; anchor = { state: "none", relays: [] }; renderAll(); +out.notSetUp = count(); +anchor = { state: "checking", relays: [] }; renderAll(); +out.checking = count(); +anchor = { state: "unavailable", relays: [] }; renderAll(); +out.unreachable = count(); +session.relay = "http://127.0.0.1:8790"; anchor = { state: "self", relays: [] }; renderAll(); +ui.device.hidden = false; +out.device = count(); +ui.device.hidden = true; +live("member"); +out.member = count(); +live("guest"); +out.guest = count(); +console.log(JSON.stringify(out)); +` + var out map[string]any + runPage(t, script, &out) + want := map[string]any{ + "unmarked": float64(0), + "start": "1/0", "invited": "1/0", "notSetUp": "1/0", "checking": "0/0", "unreachable": "0/0", + "device": "1/0", "member": "0/0", "guest": "1/0", + } + for k, w := range want { + if out[k] != w { + t.Errorf("%s: What happens next shows %v (guided/shared), want %v", k, out[k], w) + } + } +} + +// Shared's tab order follows what it shows: the toolbar along the top, then +// the documents, the brief, and the suggestions. +func TestSharedReadsInTheOrderItShows(t *testing.T) { + script := livePage(t, false) + ` +const shared = ui.app.all().find((n) => n.classList.contains("s-live")); +console.log(JSON.stringify(shared.children.map((c) => c.className.split(" ")[0]))); +` + var out []string + runPage(t, script, &out) + order := strings.Join(out, " ") + if !strings.HasPrefix(order, "toolbar sidebar main inspector") { + t.Errorf("Shared's parts are in the order %s; want the toolbar, the sidebar, the brief, then the inspector", order) + } +} + +// A phone's tab bar names each tab whole, and the guided app bar keeps the +// project's name: the sign-in shrinks to its icon and the name to an ellipsis. +func TestThePhoneBarsCutNothing(t *testing.T) { + script := livePage(t, true) + ` +const out = { member: [], guest: [] }; +live("member"); +out.member = ui.tabs.filter((b) => !b.hidden).map((b) => b.children.find((c) => c.tagName === "SPAN").textContent); +live("guest"); +out.guest = ui.tabs.filter((b) => !b.hidden).map((b) => b.children.find((c) => c.tagName === "SPAN").textContent); +out.signIn = ui.signInTop.all().map((n) => n.tagName + "." + n.className); +out.signInName = ui.signInTop.textContent; +console.log(JSON.stringify(out)); +` + var out struct { + Member, Guest, SignIn []string + SignInName string + } + runPage(t, script, &out) + // Four tabs share a 366 px bar on a 390 px phone; at the tab's 11.5 px + // type, eleven characters is the most that shows whole. + for _, labels := range [][]string{out.Member, out.Guest} { + if len(labels) == 0 { + t.Fatal("no tabs show") + } + for _, l := range labels { + if n := len([]rune(l)); n > 11 { + t.Errorf("the tab %q is %d characters; a phone cuts it", l, n) + } + } + } + if !strings.Contains(strings.Join(out.SignIn, " "), "SVG.i") || !strings.Contains(strings.Join(out.SignIn, " "), "SPAN.tb-label") { + t.Errorf("the app bar's sign-in is %v; want an icon and a label a phone hides", out.SignIn) + } + if out.SignInName != "Sign in with GitHub" { + t.Errorf("the sign-in's name is %q", out.SignInName) + } + raw, err := assets.ReadFile("session.css") + if err != nil { + t.Fatal(err) + } + css := string(raw) + project := regexp.MustCompile(`\.top \.project \{[^}]*\}`).FindString(css) + if !strings.Contains(project, "text-overflow: ellipsis") || !strings.Contains(project, "white-space: nowrap") { + t.Errorf("the project's name in the app bar does not end in an ellipsis: %s", project) + } + names := regexp.MustCompile(`\.top \.names \{[^}]*\}`).FindString(css) + if !regexp.MustCompile(`min-width: [1-9]`).MatchString(names) { + t.Errorf("the project's name has no minimum width: %s", names) + } + if strings.Contains(regexp.MustCompile(`\.tab span \{[^}]*\}`).FindString(css), "ellipsis") { + t.Error("the phone's tab labels are cut with an ellipsis") + } +} + +// Every word in the WORDS table is one the page shows. +func TestEveryWordIsUsed(t *testing.T) { + js := pageScript(t) + block := wordsBlock(t, js) + rest := strings.Replace(js, block, "", 1) + for _, m := range regexp.MustCompile(`(?m)^ ([A-Za-z0-9]+):`).FindAllStringSubmatch(block, -1) { + if !regexp.MustCompile(`WORDS\.` + m[1] + `\b`).MatchString(rest) { + t.Errorf("WORDS.%s is never shown", m[1]) + } + } +} diff --git a/internal/surface/session/web/plain_test.go b/internal/surface/session/web/plain_test.go new file mode 100644 index 000000000..d0bd0b25b --- /dev/null +++ b/internal/surface/session/web/plain_test.go @@ -0,0 +1,168 @@ +package web + +import ( + "bytes" + "regexp" + "strings" + "testing" +) + +// wordsBlock is the WORDS table's source. +func wordsBlock(t *testing.T, js string) string { + t.Helper() + i := strings.Index(js, "const WORDS = Object.freeze({") + j := strings.Index(js[i:], "\n});\n") + if i < 0 || j < 0 { + t.Fatal("session.js has no WORDS table") + } + return js[i : i+j] +} + +// TestThePageAsksForNoRelayAddressOrToken: the page has no field for a relay +// address or a token, never says either word to the person, and does not +// print the invite link, which may carry a relay (adr-2610161400542916, +// iss-2610161332024554). +func TestThePageAsksForNoRelayAddressOrToken(t *testing.T) { + js := pageScript(t) + for _, gone := range []string{`type: "password"`, `id: "relay"`, `id: "token"`, "function useToken", "pasteToken", "showTokenForm", "KEY_RELAY", "relayLabel"} { + if strings.Contains(js, gone) { + t.Errorf("session.js still carries %s", gone) + } + } + values := regexp.MustCompile(`(?m)^ [A-Za-z0-9]+: "([^"]*)",$`).FindAllStringSubmatch(wordsBlock(t, js), -1) + if len(values) < 40 { + t.Fatalf("read %d words; the check is not looking at the table", len(values)) + } + for _, v := range values { + low := strings.ToLower(v[1]) + for _, word := range []string{"relay", "token", "personal access", "https://", "websocket"} { + if strings.Contains(low, word) { + t.Errorf("the page says %q to the person: %q", word, v[1]) + } + } + } + if strings.Contains(functionBody(t, js, "renderSide"), `class: "link"`) { + t.Error("the invite card prints the link") + } + if strings.Contains(functionBody(t, js, "standingRefusal"), "relay") { + t.Error("a refusal names the relay") + } +} + +// TestAPageTheRelayServesIsItsOwnRelay: a local session's relay serves the +// page with a marker, and the page then takes its own origin as its relay, +// with no relay list to read; the static page never carries the marker. +func TestAPageTheRelayServesIsItsOwnRelay(t *testing.T) { + served := RelayFiles() + if bytes.Count(served["index.html"], []byte(RelayMarker)) != 1 { + t.Fatal("the relay's page does not carry the marker once") + } + if _, ok := served["404.html"]; ok { + t.Error("the relay serves a 404.html") + } + for _, f := range Files("/") { + if f.Path != "session.js" && bytes.Contains(f.Data, []byte("abcd-session-relay")) { + t.Errorf("the static page's %s carries the relay marker", f.Path) + } + if f.Path != "index.html" && f.Path != "404.html" && !bytes.Equal(served[f.Path], f.Data) { + t.Errorf("the relay serves %s other than as written", f.Path) + } + } + js := pageScript(t) + if !strings.Contains(functionBody(t, js, "selfServed"), `document.querySelector('meta[name="abcd-session-relay"]')`) { + t.Error("the page does not read the relay marker") + } + choose := functionBody(t, js, "chooseRelay") + if !strings.Contains(choose, `if (anchor.state === "self") return location.origin;`) { + t.Errorf("a page the relay serves does not take its own origin:\n%s", choose) + } + standing := functionBody(t, js, "relayStanding") + if !strings.Contains(standing, `if (anchor.state === "self") return relay === location.origin ? "self" : "refused";`) { + t.Errorf("a page the relay serves would talk to another relay:\n%s", standing) + } +} + +// TestThePageFindsItsRelay: with no relay in the link, the first relay the +// repository's list names; where the list cannot be read, one the person +// confirmed for this repository; and with none, plain words saying live +// sessions are not set up, and nothing to type. +func TestThePageFindsItsRelay(t *testing.T) { + js := pageScript(t) + choose := functionBody(t, js, "chooseRelay") + for _, want := range []string{ + "if (fromLink) return fromLink;", + `if (anchor.state === "listed") return anchor.relays[0] || "";`, + `if (anchor.state === "unknown") return confirmedRelays()[0] || "";`, + } { + if !strings.Contains(choose, want) { + t.Errorf("chooseRelay does not carry %s:\n%s", want, choose) + } + } + start := functionBody(t, js, "renderStart") + for _, want := range []string{"WORDS.notSetUp", "WORDS.privateNoLink", "WORDS.cannotCheck", "ui.startCard.hidden = !usable", "ui.joinCard.hidden = !usable"} { + if !strings.Contains(start, want) { + t.Errorf("renderStart does not carry %s", want) + } + } + if !strings.Contains(js, `notSetUp: "Live sessions are not set up for this project yet. Ask your technical facilitator to set them up.",`) { + t.Error("the page does not say plainly that live sessions are not set up") + } +} + +// TestTheInviteCarriesTheRelayOnlyWhenNeeded: the invite link names the +// relay only when a guest's page could not find the same one itself. +func TestTheInviteCarriesTheRelayOnlyWhenNeeded(t *testing.T) { + js := pageScript(t) + frag := functionBody(t, js, "inviteFragment") + if !strings.Contains(frag, `const own = anchor.state === "self" || (anchor.state === "listed" && anchor.relays[0] === session.relay);`) || + !strings.Contains(frag, `(own ? "" : "relay=" + encodeURIComponent(session.relay) + "&")`) { + t.Errorf("inviteFragment names the relay where the page finds it alone:\n%s", frag) + } + for _, fn := range []string{"inviteURL", "updateFragment"} { + if !strings.Contains(functionBody(t, js, fn), "inviteFragment()") { + t.Errorf("%s builds its fragment by hand", fn) + } + } +} + +// TestTheSessionOpensOnThePressRelease: once joined or started, the page +// opens the brief's press release, never asking the person to pick a file. +func TestTheSessionOpensOnThePressRelease(t *testing.T) { + js := pageScript(t) + if !strings.Contains(functionBody(t, js, "firstFile"), "/press-release/i") { + t.Error("firstFile does not look for the press release") + } + if strings.Count(js, "openFile(firstFile())") != 1 || !strings.Contains(js, "const first = firstFile();") { + t.Error("the session does not open on firstFile") + } + if strings.Contains(js, "[...files.keys()].sort()[0]") { + t.Error("a session still opens on the first file by name") + } +} + +// TestDeviceSignInGoesThroughTheRelay: the device flow's requests go to the +// relay, which keeps the device code; the page shows only a well-formed user +// code, polls no faster than five seconds, and opens GitHub's own device page +// in a new tab without an opener. +func TestDeviceSignInGoesThroughTheRelay(t *testing.T) { + js := pageScript(t) + start := functionBody(t, js, "startDeviceSignIn") + for _, want := range []string{`relayPost("/auth/device", {})`, `/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(out.user_code)`, "interval: Math.max(5, Number(out.interval) || 5)", "ui.deviceCode.textContent = out.user_code;"} { + if !strings.Contains(start, want) { + t.Errorf("startDeviceSignIn does not carry %s:\n%s", want, start) + } + } + if strings.Contains(js, "device_code") { + t.Error("the page handles a device code, which the relay keeps") + } + poll := functionBody(t, js, "pollDevice") + if !strings.Contains(poll, `relayPost("/auth/device/token", { handle: d.handle })`) || !strings.Contains(poll, "out.interval >= 5") { + t.Errorf("pollDevice does not poll the relay at GitHub's pace:\n%s", poll) + } + if !strings.Contains(js, `ui.deviceOpen = el("a", { class: "button primary", href: GITHUB_DEVICE, target: "_blank", rel: "noopener noreferrer"`) { + t.Error("the page does not open GitHub's device page in a new tab without an opener") + } + if !strings.Contains(functionBody(t, js, "relayPost"), `credentials: "omit"`) { + t.Error("relayPost sends credentials") + } +} diff --git a/internal/surface/session/web/session.css b/internal/surface/session/web/session.css new file mode 100644 index 000000000..9303c6b1f --- /dev/null +++ b/internal/surface/session/web/session.css @@ -0,0 +1,999 @@ +/* The brief session page (spc-2610141709430146 step 3; itd-2610091016262335). + Two designs on one page: Guided (the default: large cards, numbered + areas, "What happens next") and Shared (a live team bar over the + document, floating sidebars, overlays). The design is the + [data-design] attribute on the root, switched with Ctrl+Shift+[ and + Ctrl+Shift+]. + Colours are custom properties; the cobalt accent is the one --accent + token, in light and in dark. */ + +/* ====================================================================== + Tokens. Guided's are the root's; Shared's override them. + ====================================================================== */ +:root { + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, sans-serif; + --mono: ui-monospace, "Cascadia Mono", "Roboto Mono", "Noto Sans Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace; + --accent: #2457d6; + --accent-ink: #ffffff; + --accent-soft: color-mix(in srgb, var(--accent) 12%, #ffffff); + --accent-text: color-mix(in srgb, var(--accent) 80%, #000000); + --focus: var(--accent); + --bg: #f2f5f7; + --card: #ffffff; + --card-soft: #f8fafb; + --ink: #17202a; + --muted: #4f5b67; + --line: #d6dde3; + --next: #fff6e3; + --next-line: #f0c46a; + --next-ink: #6b4300; + --ok: #1d6b3a; + --ok-soft: #e3f3e8; + --no: #9b2c2c; + --no-soft: #fbe6e4; + --wait: #7a5200; + --wait-soft: #fdf0d5; + --warn: #9a3412; + --host: #6d3fd1; + --member: #1b64c8; + --guest: #b8430f; + --on-colour: #ffffff; + --shadow: 0 1px 2px rgba(23, 32, 42, .06), 0 6px 20px rgba(23, 32, 42, .07); + --shadow-lg: 0 24px 64px rgba(0, 0, 0, .2), 0 2px 8px rgba(0, 0, 0, .08); + --bar: rgba(255, 255, 255, .78); + --scrim: rgba(16, 24, 28, .32); + --tip: #1d2024; + --tip-ink: #f2f4f5; + /* Guided controls: one height per size, a 4 px spacing scale. */ + --ctl: 56px; + --ctl-sm: 44px; + --r-ctl: 16px; + --r-ctl-sm: 12px; + --radius: 20px; + --btn-fs: 1.05rem; + --btn-fs-sm: .95rem; + --btn-pad: 24px; + --btn-pad-sm: 16px; + color-scheme: light dark; +} +@media (prefers-color-scheme: dark) { + :root { + --accent: #7aa2ff; + --accent-ink: #0a1838; + --accent-soft: color-mix(in srgb, var(--accent) 16%, #1a2128); + --accent-text: color-mix(in srgb, var(--accent) 70%, #ffffff); + --bg: #11161b; + --card: #1a2128; + --card-soft: #151b21; + --ink: #eef2f5; + --muted: #a9b5c0; + --line: #2e3a45; + --next: #2a2212; + --next-line: #8a6a24; + --next-ink: #f6d999; + --ok: #7fd59b; + --ok-soft: #173323; + --no: #f19a92; + --no-soft: #3a1d1b; + --wait: #f3cd7b; + --wait-soft: #33290f; + --warn: #fdba74; + --host: #b49aff; + --member: #7fb2ff; + --guest: #ff9f6e; + --on-colour: #10151a; + --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px rgba(0, 0, 0, .35); + --shadow-lg: 0 24px 72px rgba(0, 0, 0, .6), 0 2px 8px rgba(0, 0, 0, .4); + --bar: rgba(26, 33, 40, .78); + --scrim: rgba(0, 0, 0, .5); + --tip: #eef1f2; + --tip-ink: #1a1e23; + } +} +[data-design="shared"] { + --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 20px; --s6: 24px; --s8: 32px; --s12: 48px; + --ctl: 44px; + --ctl-sm: 36px; + --ctl-in: 40px; + --bar-h: 52px; + --r-ctl: 999px; + --r-ctl-sm: 999px; + --radius: 24px; + --btn-fs: 17px; + --btn-fs-sm: 15px; + --btn-pad: 24px; + --btn-pad-sm: 16px; + --sbw: 256px; + --inw: 352px; + --tbh: calc(var(--s3) + var(--bar-h)); + --accent-soft: color-mix(in srgb, var(--accent) 12%, transparent); + --win: #f2f5f6; + --win2: #ffffff; + --ink: #1a1e23; + --muted: rgba(26, 30, 35, .70); + --faint: rgba(26, 30, 35, .50); + --line: rgba(10, 20, 30, .10); + --fill: rgba(96, 110, 122, .14); + --fill2: rgba(96, 110, 122, .08); + --glass: rgba(255, 255, 255, .66); + --glass-strong: rgba(255, 255, 255, .88); + --glass-edge: rgba(255, 255, 255, .9); + --glass-line: rgba(10, 20, 30, .09); + --card: rgba(255, 255, 255, .80); + --card-soft: var(--fill2); + --shadow: 0 12px 36px rgba(16, 36, 44, .12), 0 1px 3px rgba(16, 36, 44, .10); + --shadow-lg: 0 24px 72px rgba(16, 36, 44, .24), 0 2px 8px rgba(16, 36, 44, .12); + --live: #1f9d57; + --ok: #1d7f45; + --ok-soft: rgba(29, 127, 69, .14); + --wait: #a65300; + --wait-soft: rgba(234, 138, 0, .16); + --no: #b42335; + --no-soft: rgba(220, 40, 60, .12); + --wall: radial-gradient(900px 520px at 12% 0%, #cfdcf6 0%, transparent 70%), + radial-gradient(800px 560px at 92% 100%, #f4e4c8 0%, transparent 70%), + radial-gradient(600px 400px at 70% 20%, #dde4f4 0%, transparent 70%), #eef1f5; +} +@media (prefers-color-scheme: dark) { + [data-design="shared"] { + --accent-soft: color-mix(in srgb, var(--accent) 20%, transparent); + --win: #191c1f; + --win2: #22262a; + --ink: #eef1f2; + --muted: rgba(238, 241, 242, .72); + --faint: rgba(238, 241, 242, .50); + --line: rgba(255, 255, 255, .12); + --fill: rgba(140, 150, 160, .26); + --fill2: rgba(140, 150, 160, .15); + --glass: rgba(38, 42, 46, .64); + --glass-strong: rgba(42, 46, 51, .90); + --glass-edge: rgba(255, 255, 255, .14); + --glass-line: rgba(255, 255, 255, .08); + --card: rgba(58, 63, 68, .62); + --shadow: 0 12px 36px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .4); + --shadow-lg: 0 24px 72px rgba(0, 0, 0, .6), 0 2px 8px rgba(0, 0, 0, .4); + --live: #45d27f; + --ok: #5fd48d; + --ok-soft: rgba(95, 212, 141, .16); + --wait: #f3ad48; + --wait-soft: rgba(243, 173, 72, .16); + --no: #ff8a8a; + --no-soft: rgba(255, 110, 110, .16); + --wall: radial-gradient(900px 520px at 12% 0%, #16264a 0%, transparent 70%), + radial-gradient(800px 560px at 92% 100%, #3a2c14 0%, transparent 70%), + radial-gradient(600px 400px at 70% 20%, #232a42 0%, transparent 70%), #121517; + } +} + +/* ====================================================================== + Base, for both designs. + ====================================================================== */ +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--ink); + font: 18px/1.55 var(--font); + -webkit-font-smoothing: antialiased; +} +[data-design="shared"] body { background: var(--win); font-size: 16px; line-height: 1.5; } +h1, h2, h3 { line-height: 1.2; margin: 0; } +p { margin: 0; } +a { color: var(--accent-text); } +[hidden] { display: none !important; } +[data-design="shared"] .g-only, [data-design="guided"] .s-only { display: none !important; } +:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; border-radius: 8px; } +/* A heading takes focus only as the place a screen or dialog starts from; + it is not a control, so it draws no ring. */ +h1[tabindex="-1"]:focus-visible, h2[tabindex="-1"]:focus-visible { outline: none; } +.notice { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; color: var(--muted); } +#app { min-height: 100vh; display: flow-root; } +svg.i { width: 20px; height: 20px; flex: none; display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +svg.i .fill { fill: currentColor; stroke: none; } + +/* Buttons and fields: one height per control size. */ +button, .button, .btn { + font: inherit; font-weight: 650; font-size: var(--btn-fs-sm); line-height: 1.2; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; flex: none; + height: var(--ctl-sm); padding: 0 var(--btn-pad-sm); border-radius: var(--r-ctl-sm); white-space: nowrap; + border: 2px solid var(--line); background: var(--card); color: var(--ink); text-decoration: none; +} +.btn:not(.small), .start button.btn, .device .button { height: var(--ctl); padding: 0 var(--btn-pad); border-radius: var(--r-ctl); font-size: var(--btn-fs); gap: 12px; } +button:hover, .button:hover { border-color: var(--accent); } +.primary, button.primary, a.button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +.primary:hover { filter: brightness(1.08); } +button:disabled { opacity: .5; cursor: default; filter: none; } +.btn.block { width: 100%; } +.btn svg.i, button svg.i, .button svg.i { width: 20px; height: 20px; } +.danger { color: var(--no); } +input, select, textarea { + font: inherit; color: var(--ink); background: var(--card-soft); + border: 2px solid var(--line); border-radius: var(--r-ctl); padding: 0 16px; width: 100%; margin: 0; +} +input, select { height: var(--ctl); } +textarea { min-height: 120px; resize: vertical; padding: 12px 16px; display: block; line-height: 1.45; } +input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--focus); outline-offset: 1px; } +label, .field { display: block; font-weight: 650; margin: 0; line-height: 1.3; font-size: .95rem; } +.field-group { display: grid; gap: 8px; } +.hint { color: var(--muted); font-size: .92rem; } +.state { font-size: .9rem; color: var(--muted); } +.state.bad, .status.bad { color: var(--warn); } +.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } +.code-input { font-family: var(--mono); font-size: 1.5rem; letter-spacing: .3em; text-transform: uppercase; text-align: center; padding-left: calc(16px + .3em); } +.owner { color: var(--ink); font-weight: 600; } +.owner strong { font-size: 1.05rem; } + +/* Avatars, badges and counts. */ +.av { + --sz: 36px; width: var(--sz); height: var(--sz); border-radius: 50%; flex: none; + display: inline-grid; place-items: center; background: var(--pc, var(--muted)); color: #ffffff; + font: 700 calc(var(--sz) * .42)/1 var(--font); +} +.av.md { --sz: 36px; } +.av.sm { --sz: 28px; } +.av.xs { --sz: 20px; font-size: 10px; } +.av.guest-av { --pc: var(--guest); } +.badge { + display: inline-flex; align-items: center; height: 24px; padding: 0 10px; border-radius: 999px; flex: none; + font-size: .78rem; font-weight: 700; line-height: 1; white-space: nowrap; border: 1.5px solid currentColor; + color: var(--muted); text-transform: capitalize; +} +.badge.host { color: var(--host); } +.badge.member { color: var(--member); } +.badge.guest { color: var(--guest); } +.count { + display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 8px; border-radius: 999px; + background: var(--wait-soft); color: var(--wait); font-size: .9rem; font-weight: 800; line-height: 1; flex: none; +} + +/* The toast. */ +.toast { + position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 90; + max-width: min(36rem, calc(100% - 32px)); background: var(--tip); color: var(--tip-ink); + padding: 12px 20px; border-radius: 16px; font-size: .95rem; font-weight: 600; box-shadow: var(--shadow-lg); +} +[data-design="shared"] .toast { border-radius: 22px; min-height: 44px; display: flex; align-items: center; } + +/* The editor, in either design's page. */ +.page .filetitle { font-size: .82rem; color: var(--muted); word-break: break-all; margin: 0 0 8px; } +.editor .cm-editor { height: 100%; background: transparent; } +.editor .cm-editor.cm-focused { outline: none; } +.editor .cm-gutters { display: none; } +.editor .cm-scroller { font-family: var(--font); line-height: inherit; } +.editor .cm-content { padding: 0; caret-color: var(--ink); } +.editor .cm-line { padding: 0; } +.editor .cm-activeLine { background: transparent; } +/* The brief reads as a document: no underlined headings or links. */ +.editor .cm-content span { text-decoration: none !important; } +.editor .cm-selectionBackground, .editor .cm-focused .cm-selectionBackground { background: color-mix(in srgb, var(--accent) 26%, transparent) !important; } +.editor .cm-ySelectionInfo { font: 700 11px/1 var(--font); padding: 2px 5px; border-radius: 6px 6px 6px 0; } + +/* Warnings. */ +.notes { border: 2px solid var(--warn); border-radius: var(--radius); padding: 16px; background: var(--card); } +.notes h2 { font-size: .95rem; color: var(--warn); margin-bottom: 8px; } +.notes ul { margin: 0; padding-left: 1.1rem; color: var(--warn); font-size: .9rem; display: grid; gap: 4px; } + +/* Passages and suggestions, in both designs. */ +del, ins { display: block; white-space: pre-wrap; text-decoration: none; overflow-wrap: anywhere; } +.diff { display: grid; gap: 8px; font-size: 1rem; } +.diff .old, .diff .new { padding: 8px 12px; border-radius: 12px; } +.diff .old { background: var(--no-soft); } +.diff .old del { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--no) 70%, transparent); } +.diff .new { background: var(--ok-soft); } +.diff .k { display: block; font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); } +.sug { display: grid; gap: 12px; } +.sug-h { display: flex; align-items: center; gap: 12px; } +.sug-h .who { flex: 1; min-width: 0; } +.sug-h b { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .95rem; line-height: 1.3; } +.sug-h small { display: block; font-size: .82rem; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; } +.sug .acts { display: flex; gap: 12px; align-items: center; } +.sug .acts .btn { flex: 1 1 0; min-width: 0; } +.status { display: inline-flex; align-items: center; gap: 6px; font-weight: 750; font-size: .82rem; height: 26px; padding: 0 10px; border-radius: 999px; width: fit-content; line-height: 1; white-space: nowrap; } +.status svg.i { width: 14px; height: 14px; stroke-width: 2.4; } +.mine-h .status.wait { background: var(--wait-soft); color: var(--wait); } +.mine-h .status.ok { background: var(--ok-soft); color: var(--ok); } +.mine-h .status.no { background: var(--no-soft); color: var(--no); } +.my-sugs { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } +.mine-item { display: grid; gap: 8px; } +.mine-h { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; justify-content: space-between; } +.mine-h b { min-width: 0; overflow-wrap: anywhere; } +.mine-item del { color: var(--muted); text-decoration: line-through; font-size: .9rem; } +.mine-item ins { background: var(--ok-soft); border-radius: 8px; padding: 4px 8px; } +.people { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.person { display: flex; align-items: center; gap: 12px; } +.person .nm { flex: 1; min-width: 0; font-weight: 650; overflow-wrap: anywhere; } +.docs { display: flex; flex-direction: column; gap: 4px; } +.docs .folder { font-size: .78rem; color: var(--muted); margin: 12px 4px 4px; word-break: break-all; } +.doc-tab { + width: 100%; height: auto; min-height: 44px; text-align: left; position: relative; + display: grid; grid-template-columns: 24px minmax(0, 1fr); column-gap: 12px; align-items: center; align-content: center; + padding: 8px 12px; border-radius: 16px; border: 2px solid transparent; background: none; color: var(--ink); font-weight: 400; + white-space: normal; +} +.doc-tab svg.i { width: 22px; height: 22px; color: var(--accent-text); grid-row: 1 / span 2; } +.doc-tab .t { font-size: 1rem; line-height: 1.3; font-weight: 650; overflow-wrap: anywhere; } +.doc-tab small { color: var(--muted); font-size: .8rem; line-height: 1.3; overflow-wrap: anywhere; } +.doc-tab .chev { display: none; } +.doc-tab:hover { border-color: var(--line); background: var(--card-soft); } +.doc-tab[aria-current="true"], .doc-tab[aria-current="true"]:hover { border-color: var(--accent); background: var(--accent-soft); } + +/* The invite. */ +.invite { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 20px; align-items: center; } +.qr { width: 132px; height: 132px; display: block; background: #ffffff; border-radius: 12px; padding: 6px; border: 2px solid var(--line); image-rendering: pixelated; } +.qr-cap { display: block; text-align: center; font-size: .78rem; color: var(--muted); margin-top: 8px; } +.invite .stack { display: grid; gap: 8px; justify-items: start; } +.joincode { display: flex; gap: 4px; font: 800 1.8rem/1.2 var(--mono); letter-spacing: .1em; } +.code-hint { font-size: .85rem; color: var(--muted); } +.targets { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; } +.invite-body > .field-group { margin-top: 8px; } + +/* Send to GitHub. */ +.changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } +.passage { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 12px; align-items: start; } +.passage > svg.i { color: var(--accent-text); margin-top: 2px; } +.passage-body { display: grid; gap: 6px; min-width: 0; } +.passage .from { font-weight: 700; word-break: break-all; } +.passage .credit { font-size: .88rem; color: var(--muted); white-space: pre-line; } +.passage del, .passage ins { padding: 6px 10px; border-radius: 10px; max-height: 12rem; overflow: auto; } +.passage del { background: var(--no-soft); } +.passage ins { background: var(--ok-soft); } +.reassure { display: flex; gap: 16px; align-items: flex-start; padding: 16px 20px; border-radius: 16px; background: var(--accent-soft); } +.reassure svg.i { width: 24px; height: 24px; color: var(--accent-text); margin-top: 2px; } +.send .state a { word-break: break-all; } + +/* What happens next (the guided design's alone). */ +.next { background: var(--next); border: 2px solid var(--next-line); border-radius: var(--radius); padding: clamp(20px, 3vw, 28px); color: var(--next-ink); } +.next h2 { font-size: 1.15rem; display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.next h2 svg.i { width: 24px; height: 24px; } +.next .one { font-size: 1.05rem; } +.steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; counter-reset: s; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); } +.steps li { counter-increment: s; display: flex; gap: 12px; align-items: flex-start; font-size: 1rem; } +.steps li::before { + content: counter(s); flex: none; width: 32px; height: 32px; border-radius: 50%; + display: grid; place-items: center; font-weight: 700; font-size: .95rem; line-height: 1; + background: var(--card); border: 2px solid var(--next-line); color: var(--ink); +} +.steps li > div { padding-top: 2px; min-width: 0; } +.steps li.here::before { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +.steps li.done::before { content: "✓"; background: var(--ok-soft); border-color: var(--ok); color: var(--ok); } +.steps li strong { display: block; color: var(--ink); } +.steps li span { color: var(--next-ink); font-size: .92rem; } + +/* The device sign-in. */ +.bigcode { + font-family: var(--mono); font-weight: 700; font-size: clamp(2rem, 6vw, 3.2rem); letter-spacing: .12em; text-align: center; + padding: 16px 12px 16px calc(12px + .12em); border-radius: 16px; background: var(--accent-soft); color: var(--ink); + overflow-wrap: anywhere; line-height: 1.2; +} +.devcode { display: grid; gap: 12px; justify-items: start; } +.devcode .bigcode { width: 100%; } +.numbered { display: grid; gap: 24px; } +.numbered .n { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 16px; align-items: start; } +.numbered .n > b { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--accent); color: var(--accent-ink); font-size: 1.1rem; line-height: 1; } +.numbered h3 { font-size: 1.15rem; line-height: 40px; margin: 0 0 8px; } +.numbered .hint + .row { margin-top: 12px; } +.waiting { display: flex; gap: 16px; align-items: center; padding: 16px 20px; border-radius: 16px; background: var(--card-soft); border: 2px dashed var(--line); } +.waiting strong { display: block; color: var(--ink); font-size: 1rem; } +.spinner { width: 28px; height: 28px; border-radius: 50%; border: 4px solid var(--line); border-top-color: var(--accent); flex: none; animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* While the device sign-in shows, it is the screen. */ +.app:has(> .device:not([hidden])) > .start, +.app:has(> .device:not([hidden])) > .work { display: none; } + +/* ====================================================================== + Guided. + ====================================================================== */ +/* The app bar stays put while the page scrolls under it. */ +.top { + position: sticky; top: 0; z-index: 24; min-height: 77px; + display: flex; align-items: center; gap: 16px; + padding: 0 clamp(16px, 3vw, 40px); border-bottom: 1px solid var(--line); + background: var(--bar); -webkit-backdrop-filter: blur(20px) saturate(1.3); backdrop-filter: blur(20px) saturate(1.3); +} +.app:not([data-screen="work"]) .live-only { display: none !important; } +.logo { width: 44px; height: 44px; border-radius: 12px; background: var(--accent); color: var(--accent-ink); display: grid; place-items: center; flex: none; } +.logo svg.i { width: 26px; height: 26px; } +/* The project's name keeps a few letters' room and ends in an ellipsis, + never one letter to a line. */ +.top .names { flex: 0 1 auto; min-width: 3.5rem; } +.top .project { font-size: 1.25rem; font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.top .sub { font-size: .9rem; color: var(--muted); line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.top .spacer { flex: 1; } +.top .status { font-size: .9rem; color: var(--muted); } +.app[data-screen="work"] .top .status { display: none; } +.tb-toggle[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-text); } +.tb-sep { width: 1px; height: 28px; background: var(--line); flex: none; } +.me { display: flex; align-items: center; gap: 12px; font-size: .95rem; min-height: 44px; } +.me .who { overflow-wrap: anywhere; } + +[data-design="guided"] .start, [data-design="guided"] .work { padding: clamp(16px, 3vw, 40px); } +[data-design="guided"] .narrow { max-width: 880px; margin: 0 auto; display: grid; gap: 24px; } +[data-design="guided"] .card { + background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); + box-shadow: var(--shadow); padding: clamp(20px, 3vw, 36px); min-width: 0; +} +[data-design="guided"] .hero h1 { font-size: clamp(1.8rem, 4vw, 2.6rem); margin-bottom: 12px; } +[data-design="guided"] .hero .lede { font-size: 1.15rem; color: var(--muted); max-width: 40rem; } +[data-design="guided"] .signed:has(.state:empty + .state:empty) { display: none; } +[data-design="guided"] .signed { display: grid; gap: 4px; } +[data-design="guided"] .notice-card { border: 2px solid var(--warn); } +[data-design="guided"] .notice-card p { font-size: 1.1rem; } +[data-design="guided"] .notice-card .row:has(> [hidden]:only-child) { display: none; } +[data-design="guided"] .notice-card .row { margin-top: 16px; } +.ask-card h2 { font-size: 1.3rem; margin-bottom: 8px; } +.quote-box { border-left: 4px solid var(--accent); padding: 12px 20px; background: var(--card-soft); border-radius: 0 12px 12px 0; margin: 16px 0 20px; overflow-wrap: anywhere; } +.choices { display: grid; gap: 24px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); align-items: stretch; } +/* Each choice card is a column whose main action sits at its foot, so the + two side-by-side buttons always share a line. */ +.choice { display: flex; flex-direction: column; } +[data-design="guided"] .choice h2 { font-size: 1.4rem; margin: 16px 0 8px; } +[data-design="guided"] .choice > p { color: var(--muted); margin-bottom: 16px; } +[data-design="guided"] .choice > p.owner { color: var(--ink); } +.card-ic { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-text); flex: none; } +.card-ic svg.i { width: 28px; height: 28px; stroke-width: 2; } +.choice-form { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; } +.choice-act { margin-top: auto; display: grid; gap: 16px; } +.host-row { display: grid; gap: 8px; } + +/* The trust confirmation and the device sign-in, as screens of cards. */ +[data-design="guided"] .trust { max-width: 880px; margin: 24px auto 0; width: calc(100% - 2 * clamp(16px, 3vw, 40px)); border: 2px solid var(--warn); display: grid; gap: 12px; } +[data-design="guided"] .trust h2 { font-size: 1.4rem; } +.trust .only { color: var(--ink); font-weight: 650; } +[data-design="guided"] .device { background: none; border: 0; box-shadow: none; padding: clamp(16px, 3vw, 40px); max-width: 960px; margin: 0 auto; display: grid; gap: 24px; } +[data-design="guided"] .device-head, +[data-design="guided"] .numbered { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: clamp(20px, 3vw, 36px); } +[data-design="guided"] .device-head h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); margin-bottom: 12px; } +[data-design="guided"] .device-head .lead { font-size: 1.15rem; color: var(--muted); } +.device-cancel { justify-content: flex-start; } + +/* The live session. */ +.g-live { max-width: 1320px; margin: 0 auto; display: grid; gap: 24px; counter-reset: area; } +.banner { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; } +.banner h1 { font-size: clamp(1.4rem, 2.6vw, 2rem); margin-bottom: 8px; overflow-wrap: anywhere; } +.banner p { color: var(--muted); } +.banner-acts { display: flex; align-items: center; gap: 16px; } +.live-dot { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: var(--ok); font-size: .95rem; white-space: nowrap; } +.live-dot::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px var(--ok-soft); flex: none; } +.live-dot.bad { color: var(--warn); } +.card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } +.card-head .btn { margin-left: auto; } +.area-title { display: flex; align-items: center; gap: 12px; font-size: 1.35rem; min-width: 0; } +.area-title .num { + width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; flex: none; + background: var(--accent-soft); color: var(--accent-text); font-size: 1.05rem; font-weight: 800; line-height: 1; +} +/* The areas number themselves, in the order they show. */ +[data-design="guided"] .area:not([hidden]) > .card-head .num::before, +[data-design="guided"] .area:not([hidden]) > div > .area-title .num::before { counter-increment: area; content: counter(area); } +.area-help { color: var(--muted); margin: 0 0 20px 52px; font-size: .98rem; } +.sheet-close, .inspector-head, .tabbar { display: none; } +.live-grid { display: grid; gap: 24px; grid-template-columns: 264px minmax(0, 1fr) 400px; align-items: start; } +.app[data-left="hidden"] .live-grid { grid-template-columns: minmax(0, 1fr) 400px; } +.app[data-right="hidden"] .live-grid { grid-template-columns: 264px minmax(0, 1fr); } +.app[data-left="hidden"][data-right="hidden"] .live-grid { grid-template-columns: minmax(0, 1fr); } +.app[data-left="hidden"] .docs-card { display: none; } +/* A hidden side keeps its numbers: it leaves the grid but stays counted. */ +.app[data-right="hidden"] .live-grid > .side { position: absolute; visibility: hidden; width: 0; height: 0; overflow: hidden; } +.live-grid > .side { display: grid; gap: 24px; min-width: 0; } +.live-grid > .full { grid-column: 1 / -1; } +.docs-card { padding: 20px 16px !important; position: sticky; top: 101px; } +.side-title { display: flex; align-items: center; gap: 8px; font-size: 1.1rem; padding: 0 4px; } +.side-title svg.i { width: 22px; height: 22px; color: var(--accent-text); } +.docs-card .hint { padding: 0 4px; margin: 4px 0 16px; font-size: .9rem; } +[data-design="guided"] .doc-tab { min-height: 64px; padding: 12px; } +.paper { + border: 2px solid var(--line); border-radius: 16px; background: var(--card-soft); + padding: clamp(20px, 3vw, 36px); font-size: 1.08rem; line-height: 1.9; min-height: 340px; min-width: 0; +} +.paper:focus-within { border-color: var(--focus); } +.edit-hint { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .92rem; margin-top: 12px; } +.edit-hint svg.i { width: 18px; height: 18px; } +.readonly-tag { display: flex; align-items: center; gap: 8px; font-size: .9rem; color: var(--muted); font-weight: 600; margin-bottom: 12px; } +.readonly-tag svg.i { width: 18px; height: 18px; } +[data-design="guided"] .side > .box { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: clamp(20px, 3vw, 36px); min-width: 0; } +[data-design="guided"] .side > .notes { border: 2px solid var(--warn); } +[data-design="guided"] .side > .guest-panel { border: 2px solid var(--accent); } +[data-design="guided"] .side .row + .suggest-form, [data-design="guided"] .side .suggest-form { margin-top: 16px; display: grid; gap: 16px; } +[data-design="guided"] .sugs { display: grid; gap: 16px; } +[data-design="guided"] .sug { border: 2px solid var(--line); border-radius: 16px; padding: 16px; background: var(--card-soft); } +[data-design="guided"] .sug .decline { color: var(--no); } +[data-design="guided"] .person { padding: 12px; border-radius: 16px; background: var(--card-soft); } +[data-design="guided"] .person .av { --sz: 44px; } +[data-design="guided"] .mine-item { padding: 16px; border-radius: 16px; background: var(--card-soft); border: 1px solid var(--line); } +.finish { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; background: var(--accent-soft) !important; border-color: var(--accent) !important; } +.finish .area-title { margin-bottom: 8px; } +.finish .area-title .num { background: var(--card); } +.finish .area-help { margin-bottom: 0; } +.actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } +[data-design="guided"] .quote { white-space: pre-wrap; background: var(--no-soft); border-radius: 12px; padding: 8px 12px; max-height: 10rem; overflow: auto; } + +/* Sending, as a screen of its own. */ +[data-design="guided"] .app[data-overlay="send"] .g-live { display: none; } +[data-design="guided"] .send { max-width: 880px; margin: 0 auto; display: grid; gap: 24px; } +[data-design="guided"] .send > .close { display: none; } +[data-design="guided"] .send-h h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); margin-bottom: 12px; } +[data-design="guided"] .send-h .state { font-size: 1.15rem; } +[data-design="guided"] .send h3 { font-size: 1.35rem; margin: 24px 0 16px; } +[data-design="guided"] .changes .passage { padding: 20px; border-radius: 16px; background: var(--card-soft); border: 2px solid var(--line); } +[data-design="guided"] .send-foot { display: grid; gap: 16px; } + +/* ---- Guided, tablet and narrow windows: the right side slides over. ---- */ +@media (max-width: 1240px) { + [data-design="guided"] .app[data-left="shown"] .live-grid { grid-template-columns: 232px minmax(0, 1fr); } + [data-design="guided"] .app[data-left="hidden"] .live-grid { grid-template-columns: minmax(0, 1fr); } + [data-design="guided"] .live-grid > .side, + [data-design="guided"] .app[data-right="hidden"] .live-grid > .side { + display: grid; position: fixed; z-index: 25; top: 93px; right: 16px; bottom: 16px; width: min(396px, calc(100% - 32px)); height: auto; + overflow-y: auto; overscroll-behavior: contain; visibility: visible; + gap: 16px; align-content: start; padding: 16px; + border-radius: 28px; border: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 90%, transparent); + -webkit-backdrop-filter: blur(24px) saturate(1.3); backdrop-filter: blur(24px) saturate(1.3); + box-shadow: var(--shadow-lg); + transition: transform .28s ease, visibility .28s; + } + [data-design="guided"] .app[data-right="hidden"] .live-grid > .side { transform: translateX(calc(100% + 32px)); visibility: hidden; } + [data-design="guided"] .inspector-head { display: flex; align-items: center; gap: 12px; padding: 4px 4px 0 8px; } + [data-design="guided"] .inspector-head h2 { font-size: 1.15rem; flex: 1; } + [data-design="guided"] .side > .box { padding: 20px; box-shadow: none; } + [data-design="guided"] .side .area-help { margin-left: 0; } + [data-design="guided"] .qr { width: 116px; height: 116px; } +} +@media (min-width: 601px) and (max-width: 1240px) { + [data-design="guided"] .app[data-right="shown"] .g-live { padding-right: 404px; } + [data-design="guided"] .app[data-right="shown"] .banner { grid-template-columns: minmax(0, 1fr); } + /* With both sides open, the documents shrink to a rail of icons. */ + [data-design="guided"] .app[data-left="shown"][data-right="shown"] .live-grid { grid-template-columns: 80px minmax(0, 1fr); } + [data-design="guided"] .app[data-right="shown"] .docs-card { padding: 12px 8px !important; } + [data-design="guided"] .app[data-right="shown"] .docs-card .side-title, + [data-design="guided"] .app[data-right="shown"] .docs-card .hint, + [data-design="guided"] .app[data-right="shown"] .docs-card .folder, + [data-design="guided"] .app[data-right="shown"] .doc-tab .t, + [data-design="guided"] .app[data-right="shown"] .doc-tab small { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; margin: 0; padding: 0; } + [data-design="guided"] .app[data-right="shown"] .doc-tab { grid-template-columns: minmax(0, 1fr); justify-items: center; padding: 0; } + [data-design="guided"] .app[data-right="shown"] .doc-tab svg.i { grid-row: auto; } +} + +/* ---- Guided, phone: one column, sheets and a tab bar. ---- */ +@media (max-width: 600px) { + [data-design="guided"] body { font-size: 17px; } + .top { padding: 0 16px; gap: 12px; min-height: 68px; } + .logo { width: 40px; height: 40px; } + .top .me .badge, .top .me .who, .top .status, .tb-sep { display: none; } + .top .tb-toggle ~ .logo { display: none; } + .app[data-screen="work"] .top .logo { display: none; } + .tb-toggle, .top .tb-signin { width: var(--ctl-sm); padding: 0 !important; } + .tb-toggle .tb-label, .top .tb-signin .tb-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + [data-design="guided"] .start, [data-design="guided"] .work { padding: 16px; } + [data-design="guided"] .narrow, .g-live { gap: 16px; } + [data-design="guided"] .card { padding: 20px; } + [data-design="guided"] .hero h1 { font-size: 1.75rem; } + [data-design="guided"] .hero .lede { font-size: 1.05rem; } + .banner, .finish { grid-template-columns: minmax(0, 1fr); } + .banner h1 { font-size: 1.45rem; } + .area-title { font-size: 1.2rem; } + .area-help { margin-left: 0; } + .actions, [data-design="guided"] .choice-act { flex-direction: column; align-items: stretch; } + .actions .btn, .actions button { width: 100%; } + .choices { gap: 16px; } + .app[data-left] .live-grid { grid-template-columns: minmax(0, 1fr) !important; gap: 16px; } + .docs-card { position: static; } + .docs-card .hint { display: none; } + [data-design="guided"] .docs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + [data-design="guided"] .doc-tab { min-height: 60px; column-gap: 8px; padding: 8px 12px; border-color: var(--line); background: var(--card-soft); } + [data-design="guided"] .doc-tab small { display: none; } + .paper { padding: 16px; font-size: 1.02rem; min-height: 0; } + /* The right side becomes bottom sheets, one panel each. */ + [data-design="guided"] .live-grid > .side, + [data-design="guided"] .app[data-right] .live-grid > .side { + position: static; transform: none; visibility: visible; width: auto; height: auto; padding: 0; border: 0; border-radius: 0; + background: none; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; display: contents; overflow: visible; + } + [data-design="guided"] .inspector-head { display: none; } + [data-design="guided"] .side > .box { position: absolute; visibility: hidden; height: 0; width: 0; overflow: hidden; padding: 0; border: 0; } + [data-design="guided"] .side > .notes { position: static; visibility: visible; height: auto; width: auto; padding: 16px; border: 2px solid var(--warn); } + [data-design="guided"] .side > .box.is-open { + visibility: visible; width: auto; height: auto; position: fixed; z-index: 40; left: 0; right: 0; bottom: 0; max-height: 80%; + overflow-y: auto; overscroll-behavior: contain; + border-radius: 28px 28px 0 0; border: 0; border-top: 1px solid var(--line); + padding: 36px 20px 32px; background: var(--card); box-shadow: 0 -12px 40px rgba(0, 0, 0, .22); + } + [data-design="guided"] .side > .box.is-open::before { + content: ""; position: absolute; top: 12px; left: 50%; width: 40px; height: 4px; margin-left: -20px; + border-radius: 4px; background: color-mix(in srgb, var(--muted) 45%, transparent); + } + .sheet-close { display: inline-flex; } + [data-design="guided"] .app:not([data-sheet=""]) .live-grid::after { content: ""; position: fixed; inset: 0; z-index: 30; background: var(--scrim); } + .tabbar { + display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); position: fixed; z-index: 20; left: 12px; right: 12px; bottom: 12px; + height: 64px; padding: 4px; border: 1px solid var(--line); border-radius: 24px; + background: var(--bar); box-shadow: 0 8px 24px rgba(0, 0, 0, .14); + -webkit-backdrop-filter: blur(20px) saturate(1.3); backdrop-filter: blur(20px) saturate(1.3); + } + .tab { + height: auto; border: 0; padding: 0; font-size: .72rem; font-weight: 650; line-height: 1; color: var(--muted); background: none; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; position: relative; border-radius: 20px; white-space: nowrap; + } + .tab svg.i { width: 24px; height: 24px; } + .tab[aria-pressed="true"] { color: var(--accent-text); background: var(--accent-soft); } + .tab-count { position: absolute; top: 4px; left: calc(50% + 6px); min-width: 20px; height: 20px; padding: 0 4px; border-radius: 999px; background: var(--no); color: #ffffff; font-size: .7rem; display: grid; place-items: center; } + .g-live { padding-bottom: 88px; } + .invite { gap: 16px; } + .qr { width: 112px; height: 112px; } + .person { padding: 8px 12px; } + .bigcode { font-size: 2rem; letter-spacing: .08em; padding-left: calc(12px + .08em); } + [data-design="guided"] .numbered .n { gap: 12px; } +} + +/* ====================================================================== + Shared. + ====================================================================== */ +.glass, .glass-strong { border: 1px solid var(--glass-line); } +.glass { background: var(--glass); -webkit-backdrop-filter: blur(28px) saturate(160%); backdrop-filter: blur(28px) saturate(160%); box-shadow: inset 0 1px 0 var(--glass-edge), var(--shadow); } +[data-design="shared"] .glass-strong { background: var(--glass-strong); -webkit-backdrop-filter: blur(36px) saturate(160%); backdrop-filter: blur(36px) saturate(160%); box-shadow: inset 0 1px 0 var(--glass-edge), var(--shadow-lg); border: 1px solid var(--glass-line); } +[data-design="shared"] button, [data-design="shared"] .button { border: 0; background: var(--fill); font-weight: 600; } +[data-design="shared"] button:hover { filter: brightness(.97); } +[data-design="shared"] .primary, [data-design="shared"] a.button.primary { background: var(--accent); color: var(--accent-ink); } +[data-design="shared"] .primary:hover { filter: brightness(1.07); } +[data-design="shared"] input, [data-design="shared"] select, [data-design="shared"] textarea { border: 1px solid var(--line); background: var(--win2); border-radius: 12px; font-size: 17px; } +[data-design="shared"] input, [data-design="shared"] select { height: 44px; } +[data-design="shared"] label, [data-design="shared"] .field { font: 600 13px/1.2 var(--font); color: var(--muted); } +[data-design="shared"] .code-input { font: 600 22px/1 var(--mono); letter-spacing: .3em; text-align: left; padding-left: 16px; } +[data-design="shared"] .badge { height: 18px; padding: 0 7px; border: 0; font: 600 11px/1 var(--font); letter-spacing: .02em; background: var(--fill); color: var(--muted); } +[data-design="shared"] .badge.host { background: var(--accent-soft); color: var(--accent-text); } +[data-design="shared"] .badge.guest { background: var(--wait-soft); color: var(--wait); } +[data-design="shared"] .count { min-width: 24px; height: 24px; padding: 0 7px; background: var(--accent); color: var(--accent-ink); font: 700 13px/1 var(--font); } +[data-design="shared"] .av { box-shadow: 0 0 0 2px var(--glass-strong); } +.appicon { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; color: var(--accent-ink); background: var(--accent); flex: none; } +.appicon svg.i { width: 30px; height: 30px; stroke-width: 1.9; } +.appicon.sm { width: 36px; height: 36px; border-radius: 12px; } +.appicon.sm svg.i { width: 20px; height: 20px; } +.appicon.soft { background: var(--accent-soft); color: var(--accent-text); } +.eyebrow { font: 600 13px/1.2 var(--font); color: var(--muted); letter-spacing: .01em; } +.illus { width: 72px; height: 72px; border-radius: 20px; display: grid; place-items: center; background: var(--wait-soft); color: var(--wait); } +.illus svg.i { width: 38px; height: 38px; stroke-width: 1.7; } +.device .illus { background: var(--accent-soft); color: var(--accent-text); } + +/* Every overlay's Close: an X, top right, at the same inset. */ +.close { position: absolute; z-index: 3; top: 16px; right: 16px; width: 36px; height: 36px; border-radius: 50%; border: 0; padding: 0; display: grid; place-items: center; background: var(--fill); color: var(--ink); } +.close svg.i { width: 18px; height: 18px; stroke-width: 2.2; } + +/* Tooltips name the action and its shortcut, the same on every platform. */ +[data-design="shared"] [data-tip]:not(.close) { position: relative; } +[data-design="shared"] [data-tip]::after { + content: attr(data-tip) " " attr(data-key); white-space: pre; position: absolute; z-index: 40; top: calc(100% + 12px); left: 0; + padding: 0 12px; height: 30px; display: flex; align-items: center; border-radius: 8px; background: var(--tip); color: var(--tip-ink); + font: 500 13px/30px var(--font); box-shadow: var(--shadow); opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .15s ease .35s; +} +[data-design="shared"] .tip-r[data-tip]::after, [data-design="shared"] .tg-right[data-tip]::after, [data-design="shared"] .close[data-tip]::after { left: auto; right: 0; } +[data-design="shared"] [data-tip]:hover::after, [data-design="shared"] [data-tip]:focus-visible::after { opacity: 1; visibility: visible; } + +/* ---- Arrival: the page's title over two cards, on a soft wall. ---- */ +[data-design="shared"] .app[data-screen="start"] { background: var(--wall); } +[data-design="shared"] .start .narrow { + min-height: 100vh; display: flex; flex-direction: column; justify-content: center; gap: var(--s6); + max-width: 880px; margin: 0 auto; padding: var(--s12) var(--s8); +} +[data-design="shared"] .hero { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: var(--s2) var(--s4); } +[data-design="shared"] .hero h1 { font: 700 34px/1.12 var(--font); letter-spacing: -.01em; overflow-wrap: anywhere; } +[data-design="shared"] .hero .lede, [data-design="shared"] .hero .status { grid-column: 1 / -1; } +[data-design="shared"] .hero .lede { margin: var(--s2) 0 0; font-size: 20px; line-height: 1.45; color: var(--muted); max-width: 40em; } +[data-design="shared"] .hero .status:empty { display: none; } +[data-design="shared"] .hero .status { font-size: 15px; color: var(--muted); } +[data-design="shared"] .start .card:not(.hero), [data-design="shared"] .trust, [data-design="shared"] .device { + border-radius: var(--radius); padding: var(--s6); display: flex; flex-direction: column; gap: var(--s3); + background: var(--glass); -webkit-backdrop-filter: blur(28px) saturate(160%); backdrop-filter: blur(28px) saturate(160%); + border: 1px solid var(--glass-line); box-shadow: inset 0 1px 0 var(--glass-edge), var(--shadow); +} +[data-design="shared"] .choices { grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: var(--s4); } +[data-design="shared"] .choice h2 { font: 700 22px/1.2 var(--font); } +[data-design="shared"] .choice > p { font-size: 17px; line-height: 1.5; color: var(--muted); } +[data-design="shared"] .choice > p.owner { color: var(--ink); font-size: 15px; } +[data-design="shared"] .card-ic { width: 44px; height: 44px; border-radius: 12px; } +[data-design="shared"] .card-ic svg.i { width: 24px; height: 24px; } +[data-design="shared"] .choice-form { margin: var(--s1) 0 0; gap: var(--s3); } +[data-design="shared"] .choice-act { padding-top: var(--s3); } +[data-design="shared"] .signed:has(.state:empty + .state:empty) { display: none; } +[data-design="shared"] .notice-card, [data-design="shared"] .ask-card { border-radius: 32px !important; padding: var(--s8) !important; background: var(--glass-strong) !important; gap: var(--s5) !important; } +[data-design="shared"] .notice-card > p { font-size: 18px; line-height: 1.5; } +[data-design="shared"] .notice-card .row:has(> [hidden]:only-child) { display: none; } +[data-design="shared"] .quote-box { border: 0; border-radius: 16px; background: var(--win2); box-shadow: 0 0 0 1px var(--line); margin: 0; font-size: 15px; color: var(--muted); } +[data-design="shared"] .ask-card .row { justify-content: flex-end; } + +/* The trust confirmation is an overlay over everything. */ +[data-design="shared"] .trust { + position: fixed; z-index: 80; top: 50%; left: 50%; transform: translate(-50%, -50%); + width: min(520px, calc(100% - 32px)); border-radius: 28px; padding: var(--s6) var(--s6) var(--s6); + background: var(--glass-strong); box-shadow: 0 0 0 100vmax var(--scrim), var(--shadow-lg); +} +[data-design="shared"] .trust h2 { font: 700 22px/1.2 var(--font); min-height: 36px; display: flex; align-items: center; padding-right: 48px; } +[data-design="shared"] .trust .row { justify-content: flex-start; flex-direction: row-reverse; } + +/* The device sign-in: one card on the wall. */ +[data-design="shared"] .app:has(> .device:not([hidden])) { background: var(--wall); } +[data-design="shared"] .device { max-width: 640px; margin: var(--s12) auto; border-radius: 32px; padding: var(--s8); gap: var(--s5); background: var(--glass-strong); width: calc(100% - 32px); } +[data-design="shared"] .device-head { display: flex; flex-direction: column; gap: var(--s5); } +[data-design="shared"] .device-head h2 { font: 700 30px/1.15 var(--font); letter-spacing: -.01em; } +[data-design="shared"] .device-head .lead { font-size: 18px; line-height: 1.5; color: var(--muted); } +[data-design="shared"] .numbered { gap: var(--s4); } +[data-design="shared"] .numbered .n { grid-template-columns: 28px minmax(0, 1fr); gap: var(--s3); } +[data-design="shared"] .numbered .n > b { width: 28px; height: 28px; font-size: 14px; background: var(--fill); color: var(--muted); } +[data-design="shared"] .numbered h3 { font: 600 17px/28px var(--font); margin: 0 0 var(--s2); } +[data-design="shared"] .devcode { display: flex; align-items: center; gap: var(--s4); padding: var(--s4) var(--s5); border-radius: 16px; background: var(--win2); box-shadow: 0 0 0 1px var(--line); } +[data-design="shared"] .devcode .bigcode { background: none; padding: 0; text-align: left; font-size: 34px; width: auto; } +[data-design="shared"] .devcode .btn { margin-left: auto; } +[data-design="shared"] .waiting { border: 0; background: none; padding: 0; gap: var(--s3); } +[data-design="shared"] .spinner { width: 20px; height: 20px; border-width: 2.5px; border-color: var(--fill); border-top-color: var(--accent); } +[data-design="shared"] .device-cancel { justify-content: flex-end; } + +/* ---- The live session: a document under a floating toolbar. ---- */ +[data-design="shared"] .work { position: fixed; inset: 0; overflow: hidden; background: linear-gradient(180deg, var(--win) 0%, var(--win2) 70%); } +[data-design="shared"] .app[data-screen="work"] .top { display: none; } +.s-live { position: absolute; inset: 0; } +.s-live .main { position: absolute; inset: 0; } +.s-live .main::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: calc(var(--tbh) + var(--s6)); background: linear-gradient(180deg, var(--win) calc(100% - 24px), transparent); z-index: 4; pointer-events: none; } + +/* The left sidebar: documents, who is here, this session. */ +.sidebar { + position: absolute; z-index: 6; top: calc(var(--tbh) + var(--s3)); bottom: var(--s3); left: var(--s3); width: var(--sbw); + border-radius: 22px; padding: var(--s3); display: flex; flex-direction: column; gap: var(--s2); overflow: auto; + transition: transform .25s ease, opacity .25s ease, visibility 0s linear 0s; +} +.app[data-left="hidden"] .sidebar { transform: translateX(calc(-100% - var(--s6))); opacity: 0; visibility: hidden; pointer-events: none; transition: transform .25s ease, opacity .25s ease, visibility 0s linear .25s; } +.sidebar > .close, .inspector > .close { display: none; } +.sb-head { display: flex; align-items: center; gap: var(--s3); padding: var(--s1) var(--s2) var(--s2); } +.sb-head b { display: block; font: 700 17px/1.2 var(--font); overflow-wrap: anywhere; } +.sb-head small { display: block; font-size: 13px; color: var(--muted); } +.sb-ttl { display: none; } +.sb-label { margin: var(--s3) var(--s2) var(--s1); font: 600 12px/1 var(--font); color: var(--faint); text-transform: uppercase; letter-spacing: .06em; } +[data-design="shared"] .docs { gap: 2px; } +[data-design="shared"] .docs .folder { margin: var(--s2) var(--s3) 2px; } +[data-design="shared"] .doc-tab { background: transparent; min-height: var(--ctl-in); grid-template-columns: 19px minmax(0, 1fr); padding: 6px var(--s2) 6px var(--s3); border: 0; border-radius: 12px; font-size: 15px; } +[data-design="shared"] .doc-tab svg.i { width: 19px; height: 19px; color: var(--muted); grid-row: auto; } +[data-design="shared"] .doc-tab .t { font: 500 15px/1.25 var(--font); } +[data-design="shared"] .doc-tab small { display: none; } +[data-design="shared"] .doc-tab:hover { background: var(--fill2); } +[data-design="shared"] .doc-tab[aria-current="true"] { background: var(--accent-soft); color: var(--accent-text); } +[data-design="shared"] .doc-tab[aria-current="true"] .t { font-weight: 600; } +[data-design="shared"] .doc-tab[aria-current="true"] svg.i { color: var(--accent-text); } +[data-design="shared"] .people-slot .box > .people { padding: 0 var(--s2); gap: var(--s2); } +[data-design="shared"] .person { min-height: 32px; font-size: 15px; } +[data-design="shared"] .person .nm { font-weight: 400; } +[data-design="shared"] .person .av { --sz: 24px; } +.sb-info { margin: 0 var(--s2); font-size: 13px; line-height: 1.5; color: var(--muted); } +.sb-foot { margin-top: auto; display: flex; flex-wrap: wrap; align-items: center; gap: var(--s3); padding: var(--s3) var(--s2) var(--s1); border-top: 1px solid var(--line); font-size: 14px; } +.sb-foot:empty { display: none; } +.sb-foot > span:not(.av) { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.sb-foot small { display: block; color: var(--muted); font-size: 12px; } +.sb-foot .btn { height: 36px; padding: 0 var(--s4); font-size: 14px; flex-basis: 100%; } + +/* The toolbar: Documents | team bar (Invite at its end) | Send | Suggestions. */ +.toolbar { position: absolute; z-index: 7; top: var(--s3); left: var(--s3); right: var(--s3); height: var(--bar-h); display: flex; align-items: center; gap: var(--s3); } +.tcap { display: flex; align-items: center; gap: 2px; height: var(--bar-h); padding: 0 6px; border-radius: calc(var(--bar-h) / 2); flex: none; } +.tbtn { + display: inline-flex; align-items: center; justify-content: center; gap: 0; height: var(--ctl-in); padding: 0 var(--s3); + border: 0; border-radius: calc(var(--ctl-in) / 2); background: transparent; color: var(--ink); font: 600 15px/1 var(--font); + white-space: nowrap; flex: none; position: relative; transition: padding .32s cubic-bezier(.3, .7, .2, 1); +} +.tbtn:hover { background: var(--fill2); } +.tbtn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-text); } +.tbtn .count { margin-left: var(--s2); } +.tb-send { position: relative; height: var(--bar-h) !important; min-width: var(--bar-h); padding: 0 var(--s4) !important; gap: 0 !important; border-radius: calc(var(--bar-h) / 2) !important; box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 32%, transparent); font-size: 15px !important; } +/* A label that folds: it fades, then its width closes, leaving the icon. + It is whole or gone, never cut mid-word, and never wraps. */ +.lbl { display: inline-block; overflow: hidden; white-space: nowrap; max-width: 12em; margin-left: var(--s2); opacity: 1; transition: max-width .32s cubic-bezier(.3, .7, .2, 1), margin-left .32s cubic-bezier(.3, .7, .2, 1), opacity .22s ease .1s; } +/* Send is an icon; hover or keyboard focus opens its label. */ +.tb-send .lbl { max-width: 0; margin-left: 0; opacity: 0; transition: max-width .32s cubic-bezier(.3, .7, .2, 1), margin-left .32s cubic-bezier(.3, .7, .2, 1), opacity .12s ease; } +.tb-send:hover .lbl, .tb-send:focus-visible .lbl { max-width: 12em; margin-left: var(--s2); opacity: 1; transition: max-width .32s cubic-bezier(.3, .7, .2, 1), margin-left .32s cubic-bezier(.3, .7, .2, 1), opacity .22s ease .1s; } +.hud { flex: 1; min-width: 0; height: var(--bar-h); border-radius: calc(var(--bar-h) / 2); display: flex; align-items: center; gap: var(--s2); padding: 0 6px 0 var(--s4); } +.hud-live { display: flex; align-items: center; gap: var(--s3); flex: none; padding-right: var(--s3); border-right: 1px solid var(--line); height: 32px; } +.hud-live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--live); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live) 22%, transparent); flex: none; animation: pulse 2s ease-in-out infinite; } +.hud-live.bad .dot { background: var(--wait); animation: none; } +.hud-live.bad b { color: var(--wait); } +@keyframes pulse { 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--live) 6%, transparent); } } +.hud-live b { display: block; font: 700 15px/1.1 var(--font); white-space: nowrap; } +.hud-live small { display: block; font-size: 12px; line-height: 1.3; color: var(--muted); white-space: nowrap; max-height: 1.3em; max-width: 9em; overflow: hidden; transition: max-height .32s ease, max-width .32s ease, opacity .2s ease; } +.tiles { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: 2px; min-width: 0; overflow: hidden; flex: 0 1 auto; } +.tile { display: flex; align-items: center; gap: var(--s2); height: var(--ctl); padding: 0 var(--s3) 0 var(--s1); border-radius: 22px; flex: none; transition: padding .32s ease; } +.tile.me { background: var(--fill2); } +.tile-n { display: flex; align-items: center; font: 600 14px/1 var(--font); white-space: nowrap; } +.tile-n .badge { margin-left: 6px; max-width: 8em; overflow: hidden; transition: max-width .32s ease, margin-left .32s ease, padding .32s ease, opacity .2s ease; } +.hud-sum { display: none; } +.hud-inv { margin-left: auto; width: var(--ctl-in); height: var(--ctl-in); padding: 0; border-radius: 50%; background: var(--fill); } +.hud-inv[aria-expanded="true"] { background: var(--accent-soft); color: var(--accent-text); } +.stack { display: inline-flex; align-items: center; } +.stack .av + .av { margin-left: -5px; } +/* Too many people for whole tiles: one summary instead. */ +.hud.crowded .tiles { position: absolute; visibility: hidden; pointer-events: none; } +.hud.crowded .hud-sum { display: flex; } +.hud-sum { align-items: center; gap: var(--s2); flex: 1; min-width: 0; border: 0; background: transparent !important; height: var(--ctl); padding: 0; text-align: left; color: var(--ink); } +.hud-sum .txt { flex: 1; min-width: 0; font-size: 14px; color: var(--muted); white-space: nowrap; overflow: hidden; } +.hud-sum .txt b { color: var(--ink); font-weight: 600; } +.hud-sum svg.i { color: var(--faint); width: 16px; height: 16px; } + +/* Labels fold, least important first, as the window narrows. */ +@media (min-width: 601px) and (max-width: 1343px) { + .tg-left .lbl { max-width: 0; margin-left: 0; opacity: 0; transition: max-width .32s cubic-bezier(.3, .7, .2, 1), margin-left .32s cubic-bezier(.3, .7, .2, 1), opacity .12s ease; } + .tg-left { padding: 0 10px; } +} +@media (min-width: 601px) and (max-width: 1250px) { .hud-live small { max-height: 0; max-width: 0; opacity: 0; } } +@media (min-width: 601px) and (max-width: 1195px) { + .tile-n .badge { max-width: 0; margin-left: 0; padding: 0; opacity: 0; } + .tile { padding-right: var(--s2); } +} +@media (min-width: 601px) and (max-width: 1056px) { + .tg-right .lbl { max-width: 0; margin-left: 0; opacity: 0; transition: max-width .32s cubic-bezier(.3, .7, .2, 1), margin-left .32s cubic-bezier(.3, .7, .2, 1), opacity .12s ease; } + .tg-right { padding: 0 10px; } +} +@media (max-width: 949px) { + .tiles { position: absolute; visibility: hidden; pointer-events: none; } + .hud-sum { display: flex; } +} + +/* The document. */ +.docscroll { position: absolute; inset: 0; overflow: auto; padding: calc(var(--tbh) + var(--s8)) calc(var(--inw) + var(--s3) + var(--s8)) var(--s12) calc(var(--sbw) + var(--s3) + var(--s8)); transition: padding .25s ease; } +.app[data-left="hidden"] .docscroll { padding-left: var(--s8); } +.app[data-right="hidden"] .docscroll { padding-right: var(--s8); } +.doc { max-width: 680px; margin: 0 auto; } +.doc .banner { display: flex; align-items: center; gap: var(--s3); padding: var(--s3) var(--s4); border-radius: 16px; background: var(--wait-soft); color: var(--ink); font-size: 15px; line-height: 1.45; margin: 0 0 var(--s6); } +.doc .banner svg.i { color: var(--wait); } +[data-design="shared"] .page .filetitle { font: 600 13px/1.3 var(--font); color: var(--muted); margin: 0 0 var(--s4); } +[data-design="shared"] .page .editor { font-size: 18px; line-height: 1.65; min-height: 60vh; } + +/* The right sidebar: the suggestions. */ +.inspector { + position: absolute; z-index: 6; top: calc(var(--tbh) + var(--s3)); right: var(--s3); bottom: var(--s3); width: var(--inw); + border-radius: 22px; display: flex; flex-direction: column; + transition: transform .25s ease, opacity .25s ease, visibility 0s linear 0s; +} +.app[data-right="hidden"] .inspector { transform: translateX(calc(100% + var(--s6))); opacity: 0; visibility: hidden; pointer-events: none; transition: transform .25s ease, opacity .25s ease, visibility 0s linear .25s; } +.insp-h { display: flex; align-items: center; gap: var(--s2); padding: var(--s4) var(--s4) 0 var(--s5); min-height: calc(var(--s4) + 36px); flex: none; } +.insp-h h2 { font: 700 19px/1.2 var(--font); flex: 1; } +.insp-sub { margin: var(--s1) var(--s5) var(--s3); font-size: 14px; line-height: 1.45; color: var(--muted); flex: none; } +.insp-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 var(--s3) var(--s6); display: flex; flex-direction: column; gap: var(--s3); } +.insp-list .box > .people, .insp-list .sugs, .insp-list .my-sugs { display: flex; flex-direction: column; gap: var(--s3); } +[data-design="shared"] .sug, [data-design="shared"] .mine-item { flex: none; background: var(--card); border-radius: 16px; padding: var(--s4); gap: var(--s3); box-shadow: 0 0 0 1px var(--glass-line); } +[data-design="shared"] .sug .acts { flex-direction: row-reverse; } +[data-design="shared"] .sug .acts .btn { height: 36px; border-radius: 999px; } +[data-design="shared"] .diff { gap: var(--s2); font-size: 15px; line-height: 1.45; } +[data-design="shared"] .diff .old, [data-design="shared"] .diff .new { display: grid; grid-template-columns: 52px 1fr; gap: var(--s2); align-items: baseline; padding: 0; background: none; } +[data-design="shared"] .diff .k { font: 600 12px/1 var(--font); color: var(--faint); letter-spacing: .04em; } +[data-design="shared"] .diff del { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--no); background: var(--no-soft); border-radius: 4px; padding: 1px 3px; } +[data-design="shared"] .diff ins { background: var(--ok-soft); border-radius: 4px; padding: 1px 3px; } +[data-design="shared"] .notes { border-radius: 16px; background: var(--card); } +[data-design="shared"] .insp-list .empty { margin: var(--s2) var(--s2); } + +/* Overlays: the guest's suggestion, the invite card, the Send sheet. */ +.popover { position: fixed; z-index: 30; top: calc(var(--tbh) + var(--s6)); left: 50%; transform: translateX(-50%); width: min(440px, calc(100% - 32px)); border-radius: 24px; padding: var(--s4) var(--s5) var(--s5); display: none; flex-direction: column; gap: var(--s3); } +.popover:has(.suggest-form:not([hidden])) { display: flex; } +.popover .box > .card-head, .popover .row.g-only { display: none; } +.pop-title { font: 700 19px/1.2 var(--font); min-height: 36px; display: flex; align-items: center; padding-right: 48px; margin-bottom: var(--s3); } +.popover .suggest-form { display: flex; flex-direction: column; gap: var(--s3); } +.popover .quote { margin: 0; padding: var(--s2) var(--s3); border-radius: 10px; background: var(--fill2); font-size: 15px; line-height: 1.45; color: var(--muted); white-space: pre-wrap; max-height: 8rem; overflow: auto; } +.popover textarea { min-height: 88px; } +.popover .row { justify-content: flex-start; flex-direction: row-reverse; } +.popover .btn { height: 36px; } +.share { position: fixed; z-index: 30; display: none; flex-direction: column; gap: var(--s4); width: min(460px, calc(100% - 24px)); top: calc(var(--tbh) + var(--s4)); right: var(--share-r, 196px); border-radius: 28px; padding: var(--s4) var(--s5) var(--s5); } +.app[data-overlay="invite"] .share { display: flex; } +.share-h { display: flex; align-items: center; gap: var(--s3); min-height: 36px; padding-right: 48px; } +.share-h div { flex: 1; min-width: 0; } +.share-h b { display: block; font: 700 17px/1.2 var(--font); } +.share-h small { display: block; font-size: 13px; color: var(--muted); } +.share .box > .card-head { display: none; } +[data-design="shared"] .invite { padding: var(--s4); border-radius: 20px; background: var(--win2); box-shadow: 0 0 0 1px var(--line); gap: var(--s4); } +[data-design="shared"] .qr { width: 136px; height: 136px; border: 0; border-radius: 8px; } +[data-design="shared"] .joincode span { width: 34px; height: 44px; border-radius: 8px; display: grid; place-items: center; background: var(--fill2); font: 700 24px/1 var(--mono); } +[data-design="shared"] .invite-body { display: grid; gap: var(--s3); } +[data-design="shared"] .invite-body > .field-group { margin-top: 0; } +/* The three actions share one row, so End session never sits alone. */ +[data-design="shared"] .targets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s2); margin: 0; } +[data-design="shared"] .targets .btn { height: 44px; border-radius: 12px; background: var(--fill2); padding: 0 var(--s2); gap: 6px; min-width: 0; } +[data-design="shared"] .targets svg.i { color: var(--accent-text); } +[data-design="shared"] .targets .danger, [data-design="shared"] .targets .danger svg.i { color: var(--no); } +[data-design="shared"] .invite .btn.primary { border-radius: 999px; } +.scrim { position: fixed; inset: 0; z-index: 20; background: var(--scrim); display: none; } +.app[data-overlay="send"] .scrim { display: block; } +[data-design="shared"] .send { + position: fixed; z-index: 30; display: flex; flex-direction: column; width: min(680px, calc(100% - 32px)); left: 50%; transform: translateX(-50%); + top: calc(var(--tbh) + var(--s6)); max-height: calc(100% - var(--tbh) - var(--s12)); border-radius: 30px; overflow: hidden; +} +[data-design="shared"] .send-in { overflow: auto; padding: var(--s6) var(--s8); display: flex; flex-direction: column; gap: var(--s4); } +[data-design="shared"] .send-h { display: flex; align-items: center; gap: var(--s4); padding-right: 48px; } +[data-design="shared"] .send-h h2 { font: 700 26px/1.2 var(--font); letter-spacing: -.01em; } +[data-design="shared"] .send-h .state { margin-top: var(--s1); font-size: 15px; } +[data-design="shared"] .send h3 { margin: var(--s2) 0 0; font: 600 13px/1 var(--font); color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } +[data-design="shared"] .changes { border-radius: 16px; background: var(--win2); box-shadow: 0 0 0 1px var(--line); gap: 0; } +[data-design="shared"] .changes > li { padding: var(--s3) var(--s4); min-height: 56px; } +[data-design="shared"] .changes > li + li { border-top: 1px solid var(--line); } +[data-design="shared"] .send-foot { padding: var(--s4) var(--s8) var(--s6); border-top: 1px solid var(--line); display: grid; gap: var(--s3); } +[data-design="shared"] .send-foot .reassure { padding: var(--s3) var(--s4); font-size: 14px; } +[data-design="shared"] .send-foot .actions { justify-content: flex-start; flex-direction: row-reverse; } +[data-design="shared"] .send-foot .actions > .state { flex-basis: 100%; } + +/* ---- Shared, tablet and narrow windows. ---- */ +@media (max-width: 1300px) { + [data-design="shared"] { --sbw: 236px; --inw: 340px; } + .docscroll { padding-left: calc(var(--sbw) + var(--s3) + var(--s6)); padding-right: calc(var(--inw) + var(--s3) + var(--s6)); } + .doc { max-width: 600px; } + /* The inspector reads as a slide-over, with its Close. */ + .inspector { background: var(--glass-strong); box-shadow: inset 0 1px 0 var(--glass-edge), var(--shadow-lg); } + .inspector > .close { display: grid; } + .insp-h { padding-right: calc(var(--s4) + 36px + var(--s3)); } +} +@media (min-width: 601px) and (max-width: 1100px) { + [data-design="shared"] { --sbw: 220px; --inw: 300px; } + .docscroll { padding-left: calc(var(--sbw) + var(--s3) + var(--s4)); padding-right: calc(var(--inw) + var(--s3) + var(--s4)); } + .app[data-left="hidden"] .docscroll { padding-left: var(--s6); } + .app[data-right="hidden"] .docscroll { padding-right: var(--s6); } + .insp-h h2 { font-size: 17px; white-space: nowrap; } +} + +/* ---- Shared, phone: two toolbar rows; a full list and sheets. ---- */ +@media (max-width: 600px) { + [data-design="shared"] { --ctl: 44px; --ctl-in: 44px; --bar-h: 48px; --tbh: calc(var(--s2) + var(--bar-h) + var(--s2) + var(--bar-h)); } + [data-design="shared"] .start .narrow { padding: var(--s6) var(--s4); justify-content: flex-start; gap: var(--s4); } + [data-design="shared"] .hero .appicon { width: 48px; height: 48px; } + [data-design="shared"] .hero h1 { font-size: 28px; } + [data-design="shared"] .hero .lede { font-size: 17px; } + [data-design="shared"] .start .card:not(.hero) { padding: var(--s5); gap: var(--s2); } + [data-design="shared"] .card-ic { display: none; } + [data-design="shared"] .choice h2 { font-size: 20px; } + [data-design="shared"] .choice > p { font-size: 16px; } + [data-design="shared"] .illus { display: none; } + [data-design="shared"] .device { padding: var(--s5); margin: var(--s4) auto; border-radius: 28px; } + [data-design="shared"] .device-head h2 { font-size: 24px; } + [data-design="shared"] .devcode { flex-direction: column; align-items: stretch; text-align: center; } + [data-design="shared"] .devcode .bigcode { text-align: center; font-size: 30px; } + [data-design="shared"] .devcode .btn { margin-left: 0; } + [data-design="shared"] .row { flex-direction: column-reverse; align-items: stretch; } + [data-design="shared"] .row > .btn, [data-design="shared"] .row > button, [data-design="shared"] .row > .button { width: 100%; } + [data-design="shared"] .close { top: var(--s5); } + [data-design="shared"] [data-tip]::after { display: none; } + + .toolbar { top: var(--s2); height: auto; display: grid; grid-template-columns: auto 1fr var(--bar-h); grid-template-areas: "left right right" "hud hud send"; gap: var(--s2); z-index: 11; } + .tcap { padding: 0 4px; } + .tc-left { grid-area: left; justify-self: start; } + .tc-right { grid-area: right; justify-self: end; } + .tb-send { grid-area: send; justify-self: end; width: var(--bar-h); padding: 0 !important; border-radius: 50% !important; } + .tb-send .lbl, .tb-send:hover .lbl, .tb-send:focus-visible .lbl { display: none; } + .hud { grid-area: hud; padding: 0 4px 0 var(--s4); gap: var(--s3); } + .hud-live { border: 0; padding: 0; height: auto; gap: var(--s2); } + .hud-live small { display: none; } + .sidebar { top: 0; left: 0; right: 0; bottom: 0; width: auto; border-radius: 0; padding: calc(var(--tbh) + var(--s4)) var(--s4) var(--s4); background: var(--win); box-shadow: none; border: 0; -webkit-backdrop-filter: none; backdrop-filter: none; z-index: 9; } + .app[data-left="hidden"] .sidebar { transform: translateY(var(--s6)); } + .sb-head { display: none; } + .sb-ttl { display: flex; align-items: center; min-height: 36px; margin: var(--s3) var(--s1) var(--s1); padding-right: 48px; font: 700 26px/1.15 var(--font); } + .sidebar > .close { display: grid; top: calc(var(--tbh) + var(--s5)); right: var(--s4); } + [data-design="shared"] .docs { background: var(--win2); border-radius: 16px; overflow: hidden; gap: 0; box-shadow: 0 0 0 1px var(--line); } + [data-design="shared"] .doc-tab { min-height: 52px; border-radius: 0; padding: 0 var(--s4); font-size: 17px; grid-template-columns: 19px minmax(0, 1fr) 16px; } + [data-design="shared"] .doc-tab + .doc-tab { border-top: 1px solid var(--line); } + [data-design="shared"] .doc-tab svg.i { color: var(--accent-text); } + [data-design="shared"] .doc-tab .chev { display: block; color: var(--faint); width: 16px; height: 16px; } + .sb-foot { display: none; } + .docscroll, .app[data-left="hidden"] .docscroll, .app[data-right="hidden"] .docscroll { padding: calc(var(--tbh) + var(--s5)) var(--s5) 120px; } + [data-design="shared"] .page .editor { font-size: 17px; } + .doc .banner { font-size: 14px; margin-bottom: var(--s4); } + .inspector { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 60%; border-radius: 32px 32px 0 0; background: var(--glass-strong); z-index: 10; } + .app[data-right="hidden"] .inspector { transform: translateY(calc(100% + var(--s6))); } + .inspector > .close { display: grid; } + .insp-h { padding-top: var(--s5); padding-right: calc(var(--s4) + 36px + var(--s3)); } + .popover { top: auto; bottom: 0; left: 0; right: 0; transform: none; width: auto; border-radius: 32px 32px 0 0; padding: var(--s3) var(--s4) var(--s4); } + .share { left: 0; right: 0; bottom: 0; top: auto; width: auto; max-height: calc(100% - var(--s8)); overflow: auto; border-radius: 32px 32px 0 0; padding: var(--s3) var(--s4) var(--s4); gap: var(--s3); } + .app[data-overlay="invite"] .scrim { display: block; } + [data-design="shared"] .invite { grid-template-columns: 1fr; justify-items: center; text-align: center; gap: var(--s3); } + [data-design="shared"] .invite .stack { justify-items: center; } + /* Too narrow for the three actions side by side: one to a row, each whole. */ + [data-design="shared"] .targets { grid-template-columns: minmax(0, 1fr); } + [data-design="shared"] .send { left: 0; right: 0; width: auto; transform: none; top: var(--s3); bottom: 0; max-height: none; border-radius: 32px 32px 0 0; } + [data-design="shared"] .send-in { padding: var(--s3) var(--s5) var(--s4); } + [data-design="shared"] .send-h h2 { font-size: 22px; } + [data-design="shared"] .send-h .appicon { display: none; } + [data-design="shared"] .send-foot { padding: var(--s3) var(--s5) var(--s4); } + [data-design="shared"] .send-foot .actions { flex-direction: column-reverse; align-items: stretch; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } + .spinner { border-top-color: var(--line); border-right-color: var(--accent); } +} diff --git a/internal/surface/session/web/session.js b/internal/surface/session/web/session.js new file mode 100644 index 000000000..caf332fb2 --- /dev/null +++ b/internal/surface/session/web/session.js @@ -0,0 +1,3885 @@ +// The brief session page (itd-2610091016262335, spc-2610141709430146 steps 3 +// and 4). +// +// A team member hosts a live session on the team's own relay; everyone else +// joins by the QR code or the six-character code. Team members edit the brief +// directly and their edits are credited to them; a guest proposes a +// replacement for a passage, which a team member applies or declines. The +// session goes back to GitHub as one pre-filled issue per changed passage, or +// as one pull request made with the submitter's own token. +// +// What this page talks to, and nothing else: its relay (its WebSocket, +// /config, and /auth/token, or /auth/device on a local session's own page), +// which is the page's own origin when the relay serves it, and otherwise the +// one the invite link or the repository's committed relay list names; +// https://api.github.com (without a token for the repository's list of its +// relays, and with the person's own token); and github.com for sign-in and +// pre-filled issues. The GitHub token +// is held in this module's memory only; it is never stored, put in a URL or +// logged, and it reaches a relay only once the repository's committed relay +// list names that relay, the relay serves this page itself, or, where the +// list cannot be read, the person has confirmed it; and only to the relay +// GitHub returned the person to after signing in. GitHub's sign-in code goes +// only to that relay, through the callback the team's app lists, and never +// to a relay the invite link or this tab's storage names. The relay's +// rejoin token, which brings this participant +// back to the session after a dropped connection or a reload, is not a GitHub +// token: it is kept in memory and in this tab's session storage. +// Every piece of text from the brief, the relay or another participant is +// rendered with textContent. +import { + Y, Awareness, encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates, + yCollab, yUndoManagerKeymap, EditorView, basicSetup, EditorState, keymap, markdown, qrcode, +} from "./vendor/collab.js"; + +// --------------------------------------------------------------------------- +// The words the page shows: one table, so they can be reviewed in one place. +// --------------------------------------------------------------------------- +const WORDS = Object.freeze({ + title: "Brief", + wrongAddress: "This page opens a project's brief for a live session. Open it from the link your team shared.", + checking: "Getting the live session ready…", + notSetUp: "Live sessions are not set up for this project yet. Ask your technical facilitator to set them up.", + privateNoLink: "This page could not find a live session for this project. If your team runs one, ask the host for the invite link. If not, ask your technical facilitator to set live sessions up.", + notOurLink: "This invite link does not lead to this project's own live session, so the page will not open it. Ask the host for a new link.", + cannotCheck: "The page could not reach GitHub to check this project's settings. Try again in a moment.", + tryAgain: "Try again", + pageFor: "This page is for", + trustHeading: "Before you continue", + trustQuestion: "The page cannot check this invite against the project's own settings, because the project is private.", + trustOnly: "Only continue if someone on the project's team sent you this link.", + trustYes: "Someone on the team sent it", + signInNotSetUp: "Signing in with GitHub is not set up for this project yet. Ask your technical facilitator. You can still join as a guest.", + signInBusy: "Too many sign-ins are in progress. Wait a few minutes, then try again.", + framedRefused: "This page does not run inside another page. Open it in its own tab:", + joinMembers: "On the project's team? Sign in with GitHub to edit the brief directly.", + joinGuests: "Everyone else: join as a guest with your name, and suggest changes for the team to take up.", + codeLabel: "Session code", + nameLabel: "Your name", + join: "Join as a guest", + codeBad: "A session code is six letters and numbers. The host can read it out to you.", + haveCode: "Have a session code?", + joinWithCode: "Join with the code", + startHint: "Sign in with GitHub to start a session on this brief. You will get a link and a QR code to invite people.", + signIn: "Sign in with GitHub", + signingInTo: "You are signing in to work on", + signInFailed: "Signing in with GitHub did not finish. Try again.", + signingIn: "Signing in…", + signedInAs: "Signed in as", + notMember: "GitHub says you cannot change this project, so you take part as a guest.", + deviceHeading: "Confirm on GitHub", + deviceOpen: "Open GitHub", + deviceCopy: "Copy the code", + deviceCopied: "Code copied.", + deviceWaiting: "Waiting for you to confirm on GitHub…", + deviceExpired: "That code has expired. Sign in again for a new one.", + deviceDenied: "Signing in was cancelled on GitHub.", + host: "Start the session", + modeLabel: "When you are done, send the changes back as", + modePR: "one pull request for the whole session", + modeIssues: "one GitHub issue per change", + loadingBrief: "Reading the brief from GitHub…", + sendingBrief: "Sharing the brief with the session…", + noBrief: "This repository has no brief at .abcd/development/brief/ on its default branch.", + githubFailed: "GitHub did not answer the request for the brief", + connecting: "Connecting…", + connected: "Live", + reconnecting: "Connection lost. Reconnecting…", + cannotConnect: "The live session could not be reached. Try again in a moment, or ask the host.", + syncing: "Loading the brief…", + ended: "The session has ended.", + expired: "The session has ended: a session lasts at most twelve hours. The host can open a new one.", + codeGone: "That session is no longer open.", + wrongCode: "That code does not match a live session. Check it with the host.", + tooManyTries: "Too many wrong codes. Wait a few minutes, then try again.", + joinPaused: "Joining is paused for a few minutes. Try again shortly.", + notThisProject: "Live sessions for this project are not set up here. Ask your technical facilitator.", + signInRefused: "GitHub did not accept the sign-in. Sign in again.", + inviteHeading: "Invite people", + inviteHint: "Scan the QR code, or open the link and type the code.", + newCode: "New code", + endSession: "End session", + endConfirm: "End the session for everyone? Edits not yet sent to GitHub are lost.", + copyLink: "Copy invite link", + linkCopied: "Invite link copied.", + noFile: "Choose a file.", + badgeHost: "host", + badgeMember: "team member", + badgeGuest: "guest", + you: "(you)", + suggestionsHeading: "Suggestions", + noSuggestions: "No suggestions waiting.", + suggestedBy: "Suggested by", + apply: "Apply", + decline: "Decline", + applied: "applied", + declined: "declined", + waiting: "waiting for a team member", + cannotApply: "That passage has changed since it was suggested, so the suggestion cannot be applied.", + guestHint: "You are a guest. Select a passage to suggest a change.", + suggest: "Suggest a change", + selectFirst: "Select a passage first.", + replacementLabel: "Replace it with", + send: "Send suggestion", + cancel: "Cancel", + suggestionSent: "Suggestion sent to the team members.", + suggestTooBig: "That suggestion is too long to send. Choose a shorter passage, or a shorter replacement.", + suggestTooFast: "Your suggestion was not sent: suggestions came too fast. Wait a moment, then send it again.", + suggestNotSent: "Your suggestion was not sent.", + suggestWait: "Your last suggestion is still on its way. Send this one when it has arrived.", + suggestLost: "The connection dropped before your last suggestion was confirmed, so it may not have reached the team. Check Your suggestions, and send it again if it is not there.", + noMembers: "No team member is connected to receive it.", + mySuggestionsHeading: "Your suggestions", + submit: "Send to GitHub", + sendHeading: "Send to GitHub", + sendIntroPR: "The session goes back as one pull request, with every credited team member named on its commit.", + sendIntroIssues: "Each changed passage goes back as its own issue. GitHub opens the issue for you to check before you submit it.", + sendNothing: "Nothing has changed since the session began.", + sendRefresh: "Refresh", + sendAsIssue: "Send as issue", + tooLongForIssue: "Too long for an issue, send as a pull request", + sendAsPR: "Send as a pull request", + openPR: "Open the pull request", + prNeedsSignIn: "Only a team member signed in with GitHub can open the pull request.", + linesAt: "lines", + lineAt: "line", + creditUnknown: "A participant the session could not credit", + prWorking: "Opening the pull request…", + prOpened: "Pull request opened:", + passageMoved: "That passage has changed since the panel showed it. Check it again, then send.", + prNoBase: "This session does not know which commit it started from, so it cannot open a pull request.", + ghUnreachable: "GitHub could not be reached. Check your connection and try again.", + ghSignIn: "GitHub did not accept your sign-in. Sign in again, then send.", + ghScope: "GitHub refused: your sign-in may not create branches, commits or pull requests in this repository. Sign in again with access to its contents and pull requests.", + ghNotFound: "GitHub could not find the repository, the branch or a team member, or your sign-in cannot see them.", + ghStale: "The new branch moved on GitHub before the commit was made. Try again.", + ghRefTaken: "GitHub would not create the session's branch; one by that name may already exist. Try again.", + ghPRRefused: "The commit is on the branch, but GitHub would not open the pull request. Open it on GitHub from branch", + ghOther: "GitHub did not complete the request. Try again in a moment.", + forgedUpdate: "An edit was not applied, because it claimed to be another participant's. It came from", + setAside: "Edits that build on a refused edit can never be applied, so this page set them aside to stay in step.", + metaVoid: "A change to the session's settings was ignored, because only the host sets them and the base, the branch and the repository are set once. It came from", + someone: "a participant the session could not credit", + guestCursor: "guest", + notesHeading: "Warnings", + pathRefused: "Not sent: this file is not one of the brief's files the session started from:", + // The two designs (Guided, the default, and Shared), switched with + // Ctrl+Shift+[ and Ctrl+Shift+]. A word with {repo}, {owner}, {name} or {n} has the + // value put in by say(), and is shown with textContent like every other. + designGuided: "Switched to the guided design", + designShared: "Switched to the shared design", + heroTitle: "Talk through the {repo} brief, together", + heroLede: "This is where the {repo} team reads and edits the project brief at the same time, and invites others to suggest changes. Nothing changes in the project until the team approves it.", + heroLedeShared: "Talk through the {repo} brief with your team and your guests, live, and change it together.", + sharedTitle: "{repo} brief", + invitedTitle: "You're invited to discuss the {repo} brief", + invitedLede: "The team is going through its brief right now and would like your view.", + brandSub: "Project brief · {owner}", + teamHeading: "I'm on the {repo} team", + codeHint: "Someone on the team gave you six letters and numbers. Type them here to join as a guest.", + nameHint: "Everyone in the session will see this name next to your suggestions.", + whatNext: "What happens next", + nextSignIn: "Sign in or enter a code", + nextHere: "You are here.", + nextJoin: "Join the session", + nextJoinHint: "You see the brief and who else is here.", + nextWork: "Work on it together", + nextWorkHint: "Edit, or suggest changes as a guest.", + nextSend: "Send it for approval", + nextSendHint: "A team member sends the changes to GitHub.", + nextInvited: "Once you join, the brief opens in front of you. You see who else is there, with a coloured name flag wherever they are working.", + nextNotSetUp: "Once live sessions are on, this page shows a button to start one. You will not need to set anything up yourself.", + nextChose: "Chose to sign in", + nextChoseHint: "On this computer.", + nextConfirm: "Confirm on GitHub", + nextOpens: "The session opens", + nextOpensHint: "You start or join the session.", + nextGuest: "When the team is done, a team member sends the changes to GitHub for approval. You can leave whenever you like; your suggestions stay with the team.", + askHeading: "Want to ask them now?", + askHint: "Copy this message and send it however you normally would.", + askMessage: "Hi, could you turn on live brief sessions for {slug}? I would like to go through the brief with the team: {page}", + askCopy: "Copy the message", + askCopied: "Message copied.", + deviceIntro: "Signing in on this computer takes one extra step: you confirm a short code on GitHub's website.", + deviceNote: "Note this code", + deviceOpenTitle: "Open GitHub and type it in", + deviceOpenHint: "GitHub opens in a new tab. Type the code there, then confirm.", + deviceBack: "Come back here", + deviceBackHint: "This page moves on by itself. Keep it open.", + bannerHost: "You're hosting the {repo} brief session", + bannerMember: "You're editing the {repo} brief with your team", + bannerGuest: "Welcome, {name}. You're a guest in the {repo} session", + bannerMemberHelp: "Edit the text directly; everyone sees your changes as you type. Review guests' suggestions on the right. When the team is happy, send it all to GitHub.", + bannerGuestHelp: "You can read everything. To suggest a change, select some words in the text, then press Suggest a change. A team member applies or declines it.", + hereCount: "{n} here", + liveHere: "Live · {n} here", + documents: "Documents", + documentsHint: "Pick one to open it.", + showDocuments: "Show the documents", + reviewPeople: "Review and people", + suggestFollow: "Suggest and follow", + close: "Close", + done: "Done", + briefArea: "The brief", + editHelp: "Click into the text to edit it.", + readHelp: "Select words in the text to suggest a change.", + readOnly: "Read only: suggest changes instead of editing", + savedForEveryone: "Saved for everyone as you type.", + sugReview: "Suggestions to review", + sugHelp: "Guests cannot edit. Apply a suggestion to put it into the text, or decline it.", + sugWaiting: "Suggestions waiting", + sugSub: "Guests suggest; the team decides. Applying puts the new words in the brief for everyone at once.", + sugShort: "Suggestions", + mineHelp: "What happened to the changes you have suggested so far.", + mineSub: "The team sees each one and applies or declines it. You see the outcome here.", + noMine: "You have not suggested anything yet.", + wordsWas: "Was", + wordsNow: "Now", + wordsSuggested: "Suggested", + youSelected: "You selected", + whoHere: "Who's here", + // The phone's tab bar: four tabs share it, so each label is short enough + // to show whole. + tabBrief: "Brief", + tabSuggest: "Suggest", + tabSent: "Sent", + tabPeople: "People", + tabInvite: "Invite", + hereNow: "Here now", + thisSession: "This session", + sessionInfoMember: "Changes save for everyone as you type. Guests suggest; the team decides.", + sessionInfoGuest: "You suggest changes; the team applies or declines them.", + youShort: "You", + signedInShort: "Signed in", + joinedAsGuest: "Joined as a guest", + inviteSomeone: "Invite someone", + inviteHelp: "Share the link, or let them scan the code or type it in.", + invitePeople: "Invite people", + inviteTitle: "Invite people to this session", + scanHint: "Scan with a phone", + joinCode: "Join code", + copyCode: "Copy code", + codeCopied: "Code copied.", + finishHeading: "Finished? Send it to GitHub", + finishHelp: "Everything from this session goes back to GitHub, naming everyone who took part. Nothing in the project changes until someone on the team accepts it there.", + submitMore: "Send to GitHub…", + sendTip: "Turn this session into proposed changes on GitHub", + suggestTip: "Select words in the brief, then suggest new ones", + whatChanged: "What changed", + sendReassure: "Sending only proposes the changes. The brief on GitHub stays exactly as it is until someone on the team accepts them.", + backToSession: "Back to the session", + sessionBar: "Session", + whoIsHere: "Who is here", +}); + +// --------------------------------------------------------------------------- +// Fixed values. +// --------------------------------------------------------------------------- +const GITHUB_API = "https://api.github.com"; +const GITHUB_WEB = "https://github.com"; +const GITHUB_AUTHORIZE = "https://github.com/login/oauth/authorize"; +const GITHUB_DEVICE = "https://github.com/login/device"; +// GitHub answers 414 to a pre-filled issue link over its server limit and does +// not document the number; the GitHub CLI stops at 8,192 bytes. The link is +// measured as GitHub receives it, encoded, in bytes, against this margin +// (research note 2026-10-14-github-roles-admission-and-issue-links.md). +const ISSUE_URL_LIMIT = 7600; +const COMMIT_HEADLINE = "Edit the brief in a live session"; +// Passages closer than this many unchanged lines are one passage. +const PASSAGE_JOIN_LINES = 1; +const BRIEF_DIR = [".abcd", "development", "brief"]; +const BRIEF_PREFIX = BRIEF_DIR.join("/") + "/"; +const TAG_UPDATE = 0; +const TAG_AWARENESS = 1; +// The relay sends every member's update on as tag 2: the tag, the sender's +// participant number as four big-endian bytes, then the update's bytes as the +// member sent them. The roster's authors map names each number's login. +const TAG_ATTRIBUTED = 2; +// And every awareness message as tag 3, the same way: the tag, the sender's +// participant number, then the message's bytes as the participant sent them. +const TAG_ATTRIBUTED_AWARENESS = 3; +// A relay message is at most 256 KiB; the seed is sent in updates of at most +// SEED_BUDGET bytes of text, and a longer file in pieces of SEED_PIECE UTF-16 +// units (three bytes each at most). +const SEED_BUDGET = 160000; +const SEED_PIECE = 50000; +const QUOTE_CONTEXT = 32; +// A suggestion a member's page records lands in the session's log, so each of +// its fields is bounded: the replacement, and the passage it replaces, at +// most MAX_REPLACEMENT characters; the quote's context at most MAX_QUOTE_PART +// each side; a relative position at most MAX_POSITION_JSON as JSON; and all of +// it at most MAX_SUGGEST_BYTES, the relay's own cap on a suggestion. +const MAX_REPLACEMENT = 64000; +const MAX_QUOTE_PART = 200; +const MAX_POSITION_JSON = 256; +const MAX_SUGGEST_BYTES = 70 * 1024; +// The relay numbers a session's suggestions s1, s2, … +const SUGGESTION_ID_RE = /^s[0-9]{1,9}$/; +// Origins of Yjs transactions: what the relay sent, and the host's seeding. +const FROM_RELAY = Symbol("relay"); +const FROM_SEED = Symbol("seed"); +const KEY_NAME = "abcd.session.name"; +const KEY_SIGN_IN = "abcd.session.sign-in"; +const KEY_REJOIN = "abcd.session.rejoin"; +// The relays a person confirmed, per repository, each with when: +// { "owner/repo": [{ relay, at }] }, in this tab's session storage. A +// confirmation is the person's word that the team sent this link: it lasts +// this tab, across GitHub's sign-in redirect, and lapses after CONFIRM_LIFE, +// the most a session lasts, so a restored tab does not carry it on. +const KEY_TRUSTED = "abcd.session.confirmed-relays"; +const CONFIRM_LIFE = 12 * 60 * 60 * 1000; +// Where an earlier page kept confirmations, in local storage and for good. +const LEGACY_TRUSTED = "abcd.session.trusted-relays"; +// The web sign-in's callback on the relay: the team's GitHub App lists the +// relay's origin followed by it, and GitHub returns the person there alone. +const CALLBACK_PATH = "/auth/callback"; +// The repository's committed list of its relays: {"relays": [origin, …]}. +const RELAYS_FILE = ".abcd/relay/relays.json"; +// A relay's rejoin token: 128 random bits, base64url without padding. +const REJOIN_RE = /^[A-Za-z0-9_-]{22}$/; +// A participant's awareness colour: the editor writes it into a style +// attribute, so a remote one is used only when it is exactly this. +const HEX_COLOUR_RE = /^#[0-9a-fA-F]{6}$/; +const COLOURS = ["#2f6fdb", "#c2410c", "#15803d", "#9333ea", "#b91c1c", "#0e7490", "#a16207", "#be185d"]; +// The page's two designs, the first the default, and the key that keeps a +// viewer's choice in this browser. The choice is a look, never a secret. +const DESIGNS = ["guided", "shared"]; +const KEY_DESIGN = "abcd.session.design"; +// The widths at which a sidebar becomes a sheet (a phone) and the right one +// a slide-over (a tablet, or a narrow window), per design. +const PHONE_MAX = 600; +const SLIDE_MAX = { guided: 1240, shared: 1300 }; +// The page's icons are inline SVG, drawn from these paths; the namespace is +// a name, never fetched. +const SVG_NS = "http://www.w3.org/2000/svg"; + +// --------------------------------------------------------------------------- +// Storage: only the display name and the viewer's design are remembered; the relays the person +// confirmed are kept for this tab, and the sign-in's PKCE verifier waits in +// session storage across GitHub's redirect. Every access may throw (private +// windows, blocked storage). +// --------------------------------------------------------------------------- +function recall(key) { + try { return localStorage.getItem(key) || ""; } catch { return ""; } +} +function remember(key, value) { + try { localStorage.setItem(key, value); } catch { /* storage unavailable */ } +} +function stashSignIn(pending) { + try { sessionStorage.setItem(KEY_SIGN_IN, JSON.stringify(pending)); return true; } catch { return false; } +} +function takeSignIn() { + try { + const raw = sessionStorage.getItem(KEY_SIGN_IN); + sessionStorage.removeItem(KEY_SIGN_IN); + return raw ? JSON.parse(raw) : null; + } catch { return null; } +} + +// trustedRelays reads this tab's confirmations of every repository; +// confirmedRelays lists this repository's that have not lapsed, and +// confirmRelay and forgetRelay change them. Nothing else is kept with them. +function trustedRelays() { + try { + const all = JSON.parse(sessionStorage.getItem(KEY_TRUSTED) || "{}"); + return all !== null && typeof all === "object" && !Array.isArray(all) ? all : {}; + } catch { return {}; } +} +function keepTrusted(all) { + try { sessionStorage.setItem(KEY_TRUSTED, JSON.stringify(all)); } catch { /* storage unavailable */ } +} +function liveConfirmations() { + const list = trustedRelays()[route.slug]; + const now = Date.now(); + return Array.isArray(list) + ? list.filter((c) => c && typeof c.relay === "string" && typeof c.at === "number" && c.at <= now && now - c.at < CONFIRM_LIFE) + : []; +} +function confirmedRelays() { + return liveConfirmations().map((c) => c.relay); +} +function confirmRelay(relay) { + const all = trustedRelays(); + all[route.slug] = liveConfirmations().filter((c) => c.relay !== relay).concat([{ relay, at: Date.now() }]).slice(-8); + keepTrusted(all); +} +function forgetRelay(relay) { + const all = trustedRelays(); + all[route.slug] = liveConfirmations().filter((c) => c.relay !== relay); + keepTrusted(all); +} + +// --------------------------------------------------------------------------- +// The GitHub token: module memory only, and only ever from a sign-in: nobody +// pastes a token. A token just signed in is held apart until the relay it +// would go to is cleared; only then does it become the token verify sends. +// --------------------------------------------------------------------------- +let githubToken = ""; +let heldToken = ""; +// tokenRelay is the relay the token was signed in through: the one GitHub +// returned the person to (the web flow), or the one that served this page +// (the device flow). The token is presented to it alone. +let tokenRelay = ""; + +// --------------------------------------------------------------------------- +// The rejoin token: the relay gives one to each participant, and presenting +// it brings the same participant back without the join code, which the host +// may have changed since. It is kept in memory, and in this tab's session +// storage with the relay and repository it belongs to, so a reload rejoins. +// --------------------------------------------------------------------------- +let rejoinToken = ""; +function keepRejoin(token) { + if (typeof token !== "string" || !REJOIN_RE.test(token)) return; + rejoinToken = token; + try { sessionStorage.setItem(KEY_REJOIN, JSON.stringify({ relay: session.relay, repo: route.slug, token })); } catch { /* storage unavailable */ } +} +function forgetRejoin() { + rejoinToken = ""; + try { sessionStorage.removeItem(KEY_REJOIN); } catch { /* storage unavailable */ } +} +// takeRejoin reads a token kept by this tab before a reload, for this +// repository: it sets the relay it belongs to and reports whether there is one. +function takeRejoin() { + let kept = null; + try { kept = JSON.parse(sessionStorage.getItem(KEY_REJOIN) || "null"); } catch { kept = null; } + if (!kept || kept.repo !== route.slug || typeof kept.token !== "string" || !REJOIN_RE.test(kept.token)) return false; + const relay = relayOrigin(kept.relay); + if (!relay || (session.relay && relay !== session.relay)) return false; + session.relay = relay; + rejoinToken = kept.token; + return true; +} + +// --------------------------------------------------------------------------- +// Small DOM helpers. Text always goes in through textContent. +// --------------------------------------------------------------------------- +function el(tag, props = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (v === undefined || v === null || v === false) continue; + if (k === "class") node.className = v; + else if (k === "text") node.textContent = v; + else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v === true ? "" : String(v)); + } + for (const c of children) { + if (c === null || c === undefined || c === false) continue; + node.append(typeof c === "string" ? document.createTextNode(c) : c); + } + return node; +} +function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} +// warn shows a warning that stays on the page, once, as well as a toast. +function warn(text) { + toast(text); + if (!notes.includes(text)) notes.push(text); + renderSide(); +} +let toastTimer = 0; +function toast(text) { + const t = ui.toast; + t.textContent = text; + t.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { t.hidden = true; }, 6000); +} + +// --------------------------------------------------------------------------- +// Where the page is: ///brief/, under any base path. +// --------------------------------------------------------------------------- +const NAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const REPO_RE = /^[A-Za-z0-9._-]{1,100}$/; +function readRoute() { + const m = /^(.*)\/([^/]+)\/([^/]+)\/brief\/?$/.exec(location.pathname); + if (!m) return null; + let owner, repo; + try { owner = decodeURIComponent(m[2]); repo = decodeURIComponent(m[3]); } catch { return null; } + if (!NAME_RE.test(owner) || !REPO_RE.test(repo) || repo === "." || repo === "..") return null; + return { base: m[1], owner, repo, slug: owner + "/" + repo }; +} +function pageURL() { + return location.origin + location.pathname; +} + +// relayOrigin reads a relay address: https, or http on this computer. +function relayOrigin(input) { + const s = String(input || "").trim(); + if (!s) return ""; + let u; + try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : "https://" + s); } catch { return ""; } + if (u.username || u.password) return ""; + const local = u.hostname === "localhost" || u.hostname === "127.0.0.1"; + if (u.protocol === "https:" || (u.protocol === "http:" && local)) return u.origin; + return ""; +} +const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; +function normalizeCode(s) { + const c = String(s || "").toUpperCase().replace(/[\s-]/g, ""); + if (c.length !== 6) return ""; + for (const ch of c) if (!CODE_ALPHABET.includes(ch)) return ""; + return c; +} + +// --------------------------------------------------------------------------- +// The shared document. gc stays off so every change can be credited. +// --------------------------------------------------------------------------- +const doc = new Y.Doc({ gc: false }); +const files = doc.getMap("files"); +const meta = doc.getMap("meta"); +const approvals = doc.getArray("approvals"); +// A guest's suggestion, recorded by the first team member whose page saw it, +// so a member who joins later sees it too; suggestions_done holds its answer. +const suggestions = doc.getMap("suggestions"); +const suggestionsDone = doc.getMap("suggestions_done"); +// Yjs records who inserted each piece of text but not who deleted it: each +// member's page records its own deletions here, under its Yjs client id. +const deletions = new Y.PermanentUserData(doc, doc.getMap("deletions")); +const awareness = new Awareness(doc); + +// --------------------------------------------------------------------------- +// Page state. +// --------------------------------------------------------------------------- +const route = readRoute(); +const session = { + relay: "", + code: "", + name: "", + login: "", + member: false, // GitHub says this login may push + role: "", // "member" or "guest", once in the session + host: false, + myId: "", + inRoom: false, // joined or hosted on the current socket + rejoining: false, // a rejoin token was presented on the current socket + everJoined: false, // reconnect only once a session was reached + seeding: false, + synced: false, + over: false, + verifying: false, + pending: "", // "host" when the host button waits for a socket + roster: [], + credits: {}, // Yjs client id -> login, from the relay's roster + authors: {}, // participant number -> login, from the relay's roster + hostLogin: "", // the login that opened the session, from the relay's roster +}; +// Attributed updates wait here until a roster on this connection names their +// senders: the relay replays a session's log before it sends the roster. +const held = []; +let rosterSeen = false; +// Client ids seen in an unattributed (tag 0) update, from a relay that does +// not attribute: their text is applied but credited to nobody. +const unattributed = new Set(); +const mine = []; // a guest's own suggestions +const notes = []; // warnings the page keeps showing +// The guest's suggestion sent and not yet answered. The relay's answer, a +// refusal or a rate limit included, does not say which message it answers, +// so a guest has one suggestion in flight at a time. Every refusal names +// kind "suggest"; one no answer comes for in SUGGEST_TIMEOUT is given up on. +let suggestionInFlight = null; +let suggestTimer = 0; +const SUGGEST_TIMEOUT = 15000; +let ws = null; +let retries = 0; +let retryTimer = 0; +let outbox = []; // a member's updates made while disconnected +// A member's updates sent in the last few seconds: the relay drops an update +// that comes too fast and says only "rate-limited", so they are all sent +// again, merged, a moment later. Yjs applies an update it already has as +// nothing, so sending one twice is harmless. +let recentUpdates = []; +let resendTimer = 0; +const RESEND_WINDOW = 5000; +let quietClose = false; // the relay closed an idle connection normally + +// --------------------------------------------------------------------------- +// The relay connection. +// --------------------------------------------------------------------------- +function socketOpen() { + return ws !== null && ws.readyState === WebSocket.OPEN; +} +function sendJSON(msg) { + if (socketOpen()) ws.send(JSON.stringify(msg)); +} +function sendBinary(tag, bytes) { + const frame = new Uint8Array(bytes.length + 1); + frame[0] = tag; + frame.set(bytes, 1); + if (socketOpen() && session.inRoom) { + ws.send(frame); + return true; + } + return false; +} + +function openSocket() { + if (ws !== null || session.over || !session.relay || !relayCleared()) return; + setStatus(session.everJoined ? WORDS.reconnecting : WORDS.connecting, session.everJoined); + const url = session.relay.replace(/^http/, "ws") + "/session"; + const sock = new WebSocket(url); + sock.binaryType = "arraybuffer"; + rosterSeen = false; + ws = sock; + sock.onopen = () => { if (ws === sock) afterOpen(); }; + sock.onmessage = (ev) => { if (ws === sock) receive(ev.data); }; + sock.onclose = (ev) => { if (ws === sock) socketClosed(ev); }; +} + +function afterOpen() { + if (rejoinToken) { + session.rejoining = true; + sendJSON({ type: "rejoin", token: rejoinToken }); + return; + } + if (githubToken && relayTokenCleared()) { + session.verifying = true; + sendJSON({ type: "verify", token: githubToken, repo: route.slug }); + return; + } + proceed(); +} + +// proceed joins or hosts once the connection is open and any sign-in checked. +function proceed() { + if (session.code) { + sendJSON({ type: "join", code: session.code, name: session.name }); + } else if (session.pending === "host") { + session.pending = ""; + sendJSON({ type: "host", repo: route.slug }); + } + renderStart(); +} + +function socketClosed(ev) { + ws = null; + session.inRoom = false; + session.verifying = false; + session.rejoining = false; + const quiet = quietClose; + quietClose = false; + // No answer comes on a closed connection. + clearTimeout(suggestTimer); + if (suggestionInFlight) { + suggestionInFlight = null; + toast(WORDS.suggestLost); + } + renderTop(); + if (session.over) return; + // The relay ended the session. Its closed message comes first, unless the + // close cut a log replay short: then the close's reason says so. + if (ev.code === 1000 && (ev.reason === "session closed" || ev.reason === "session expired")) { + endSession(ev.reason === "session expired" ? WORDS.expired : WORDS.ended); + return; + } + // The relay closes a connection that joined nothing in time; the next + // Join or Host opens another. + if (quiet && !session.everJoined) { + setStatus("", false); + renderStart(); + return; + } + if (ev.code === 1008) { + endSession(ev.reason ? ev.reason : WORDS.cannotConnect); + return; + } + if (!session.everJoined) { + setStatus(WORDS.cannotConnect, true); + renderStart(); + return; + } + setStatus(WORDS.reconnecting, true); + const delay = Math.min(30000, 1000 * 2 ** retries) * (0.5 + Math.random() / 2); + retries += 1; + clearTimeout(retryTimer); + retryTimer = setTimeout(openSocket, delay); +} + +function endSession(text) { + session.over = true; + session.inRoom = false; + forgetRejoin(); + clearTimeout(retryTimer); + if (ws) { + const sock = ws; + ws = null; + try { sock.close(); } catch { /* already closed */ } + } + setStatus(text, true); + toast(text); + renderAll(); +} + +function receive(data) { + if (data instanceof ArrayBuffer) { + const bytes = new Uint8Array(data); + if (bytes.length === 0) return; + const body = bytes.subarray(1); + try { + if (bytes[0] === TAG_ATTRIBUTED) { + if (bytes.length < 5) return; + const num = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1); + const update = bytes.slice(5); + if (!rosterSeen) held.push({ num, update }); + else admitUpdate(num, update); + } else if (bytes[0] === TAG_UPDATE) { + // An older relay's update, which names no sender: applied, credited + // to nobody. + noteUnattributed(body); + Y.applyUpdate(doc, body, FROM_RELAY); + settlePending(); + } else if (bytes[0] === TAG_ATTRIBUTED_AWARENESS) { + if (bytes.length < 5) return; + admitAwareness(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1), bytes.subarray(5)); + } + // An awareness message that names no sender (tag 1) is not applied: the + // relay sends every one on as tag 3. + } catch { + // A frame that does not decode is dropped; the relay forwards only + // what a participant's page sent. + } + return; + } + let msg; + try { msg = JSON.parse(data); } catch { return; } + if (!msg || typeof msg.type !== "string") return; + switch (msg.type) { + case "verified": onVerified(msg); break; + case "hosted": onHosted(msg); break; + case "joined": onJoined(msg); break; + case "synced": onSynced(); break; + case "roster": onRoster(msg); break; + case "suggest": onSuggest(msg); break; + case "suggested": onSuggested(msg); break; + case "code": onCode(msg); break; + case "closed": endSession(msg.reason === "expired" ? WORDS.expired : WORDS.ended); break; + case "error": onError(msg); break; + default: break; + } +} + +// forgedUpdate says whether an attributed update holds anything its sender +// may not have written: a struct whose client id the roster does not credit +// to the sender's login, or a sender the roster does not name. Its deletions +// are not checked: a member may delete anyone's text, and who deleted it is +// credited from the deletion records, checked in verifiedDeleters. +function forgedUpdate(num, update) { + const login = session.authors[String(num)]; + if (typeof login !== "string" || login === "") return true; + let decoded; + try { decoded = Y.decodeUpdate(update); } catch { return true; } + for (const s of decoded.structs) { + if (creditedTo(s.id.client) !== login) return true; + } + return false; +} + +// admitUpdate applies an attributed update, or drops all of it and says +// whose it was (security review F6). +function admitUpdate(num, update) { + if (forgedUpdate(num, update)) { + const who = session.authors[String(num)]; + toast(WORDS.forgedUpdate + " " + (typeof who === "string" && NAME_RE.test(who) ? "@" + who : "participant p" + num) + "."); + noteRefused(update); + } else { + try { Y.applyUpdate(doc, update, FROM_RELAY); } catch { /* an update that does not decode is dropped */ } + } + settlePending(); +} + +// Yjs holds an update that depends on clocks it has not seen as pending, and +// tries it again as each update arrives. Every page refuses the same forged +// update, so nothing can ever supply its clocks, and an update that builds on +// them would wait for ever. refusedFrom is each client id a refused update +// carried, with the lowest clock it carried there. +const refusedFrom = new Map(); +function noteRefused(update) { + let decoded; + try { decoded = Y.decodeUpdate(update); } catch { return; } + for (const s of decoded.structs) { + const { client, clock } = s.id; + if (!refusedFrom.has(client) || clock < refusedFrom.get(client)) refusedFrom.set(client, clock); + } +} + +// settlePending sets aside, with a note, what Yjs holds pending that waits on +// a refused update's clocks. Yjs keeps its pending structs as one merged +// update, so all of it goes; pending that waits on nothing refused arises only +// when the relay dropped an update for its rate, and the writer's page sends +// its recent updates again after that. +function settlePending() { + if (refusedFrom.size === 0) return; + const store = doc.store; + const waits = (client, clock) => refusedFrom.has(client) && clock >= refusedFrom.get(client); + let setAside = false; + if (store.pendingStructs && [...store.pendingStructs.missing].some(([client, clock]) => waits(client, clock))) { + store.pendingStructs = null; + setAside = true; + } + if (store.pendingDs) { + let stuck = true; + try { + stuck = [...Y.decodeUpdateV2(store.pendingDs).ds.clients].some(([client, ranges]) => ranges.some((r) => waits(client, r.clock + r.len - 1))); + } catch { stuck = true; } + if (stuck) { + store.pendingDs = null; + setAside = true; + } + } + if (setAside) warn(WORDS.setAside); +} + +function noteUnattributed(update) { + for (const s of Y.decodeUpdate(update).structs) { + if (s.id.client !== doc.clientID) unattributed.add(s.id.client); + } +} + +function onVerified(msg) { + const wasVerifying = session.verifying; + session.verifying = false; + session.login = typeof msg.login === "string" ? msg.login : ""; + session.member = msg.member === true; + if (session.inRoom) { + // A participant who joined by code and then signed in. + if (session.member) becomeMember(); + else toast(WORDS.notMember); + renderAll(); + return; + } + if (!session.member && !session.code) toast(WORDS.notMember); + if (wasVerifying) proceed(); + renderAll(); +} + +function onHosted(msg) { + keepRejoin(msg.rejoin); + session.code = normalizeCode(msg.code); + session.myId = String(msg.id || ""); + session.host = true; + session.role = "member"; + session.inRoom = true; + session.everJoined = true; + session.seeding = true; + retries = 0; + setStatus(WORDS.connected, false); + sendJSON({ type: "register", client: doc.clientID }); + renderAll(); + hostBrief().catch((err) => { + // A session that cannot be seeded is closed again: nobody has its code. + sendJSON({ type: "close" }); + session.seeding = false; + endSession(err instanceof Error && err.message ? err.message : WORDS.githubFailed); + }); +} + +function onJoined(msg) { + keepRejoin(msg.rejoin); + const rejoined = session.rejoining; + session.rejoining = false; + session.myId = String(msg.id || ""); + session.role = msg.role === "member" ? "member" : "guest"; + session.host = msg.host === true; + if (typeof msg.name === "string" && session.role === "guest") session.name = msg.name; + session.inRoom = true; + session.everJoined = true; + retries = 0; + setStatus(session.synced ? WORDS.connected : WORDS.syncing, false); + // Back by the rejoin token after signing in on this page: the relay has not + // seen this sign-in yet. + if (rejoined && githubToken && relayTokenCleared() && !session.login && session.role !== "member") sendJSON({ type: "verify", token: githubToken }); + renderAll(); +} + +function onSynced() { + if (session.seeding) return; // the host's own fresh session: the seed follows + session.synced = true; + setStatus(WORDS.connected, false); + if (session.role === "member") becomeMember(); + shareAwareness(); + renderAll(); +} + +function becomeMember() { + const was = session.role; + session.role = "member"; + sendJSON({ type: "register", client: doc.clientID }); + trackDeletions(); + const queued = outbox; + outbox = []; + for (const frame of queued) { + if (!sendBinary(TAG_UPDATE, frame)) outbox.push(frame); + else keepRecent(frame); + } + if (was !== "member") openFile(currentPath); +} + +function onRoster(msg) { + const before = new Set(session.roster.map((p) => p.id)); + session.roster = Array.isArray(msg.participants) ? msg.participants.filter((p) => p && typeof p.id === "string") : []; + session.credits = msg.clients && typeof msg.clients === "object" ? msg.clients : {}; + session.authors = msg.authors && typeof msg.authors === "object" ? msg.authors : {}; + // The login that opened the session: the relay names it, also while the + // host is away, and marks the host among the participants. + const named = session.roster.find((p) => p.host === true && p.role === "member" && typeof p.login === "string" && NAME_RE.test(p.login)); + if (typeof msg.host === "string" && NAME_RE.test(msg.host)) session.hostLogin = msg.host; + else if (named) session.hostLogin = named.login; + // A guest's awareness client id is tied to them while they are here. + for (const num of [...guestAwareness.keys()]) { + if (!session.roster.some((p) => p.id === "p" + num)) guestAwareness.delete(num); + } + rosterSeen = true; + for (const h of held.splice(0)) admitUpdate(h.num, h.update); + watchMeta(); + for (const p of session.roster) { + if (p.id === session.myId) { + session.host = p.host === true; + if (p.role === "member" && session.role !== "member" && session.synced) becomeMember(); + } + } + // A newcomer sees the others' cursors once they say where they are. + if (session.roster.some((p) => !before.has(p.id))) shareAwareness(); + renderSide(); + renderTop(); +} + +function onCode(msg) { + const code = normalizeCode(msg.code); + if (code) { + session.code = code; + updateFragment(); + renderSide(); + } +} + +function onError(msg) { + const code = typeof msg.code === "string" ? msg.code : ""; + const text = typeof msg.message === "string" ? msg.message : code; + if (session.rejoining && code === "bad-rejoin") { + // The session no longer knows this token: sign in and join by the code + // instead, on the same connection. + session.rejoining = false; + forgetRejoin(); + afterOpen(); + return; + } + if (code === "rejoined-elsewhere" || code === "too-many-tries") forgetRejoin(); + if (msg.kind === "suggest" || code === "suggestion-too-big" || code === "too-many-suggestions" || code === "suggestions-full" || code === "bad-suggestion") { + // This refused the one suggestion in flight: the relay names every + // refusal of a suggest by its kind, a rate limit's included, so a + // dropped cursor ("awareness") leaves it alone. A suggestion dropped for + // coming too fast may be sent again. + const was = suggestionInFlight; + suggestionInFlight = null; + clearTimeout(suggestTimer); + if (was) toast(code === "rate-limited" ? WORDS.suggestTooFast : WORDS.suggestNotSent + " " + text); + renderSide(); + return; + } + if (code === "join-timeout") { + // A normal close follows; a session in progress reconnects and rejoins. + quietClose = true; + return; + } + if (code === "rate-limited" && msg.kind === "update" && session.role === "member") { + // The relay dropped an update that came too fast, and says so by its + // kind, so the recent ones go again. A dropped cursor needs nothing: the + // next one replaces it. + resendRecent(); + return; + } + if (code === "rate-limited") { + // A dropped cursor needs nothing, as the next one replaces it, and must + // never read as a failed sign-in or a toast per drag. Only a refused + // session start is worth saying. + if (msg.kind === "host") toast(text); + return; + } + if (session.verifying) { + session.verifying = false; + githubToken = ""; + toast(plainError(code, text)); + proceed(); + return; + } + if (code === "repo-not-served") { + if (session.everJoined) { endSession(WORDS.notThisProject); return; } + session.code = ""; + session.pending = ""; + toast(WORDS.notThisProject); + renderStart(); + return; + } + if (code === "paused" && session.everJoined) { + // Joining by code is paused on the relay for a while: try again later. + toast(WORDS.joinPaused); + if (ws) ws.close(); + return; + } + if (code === "bad-code" || code === "too-many-tries" || code === "paused") { + if (session.everJoined) endSession(WORDS.codeGone); + else { + // The connection stays open: the person can type the code again, in + // the code field a link's code had hidden. + session.code = ""; + linkCode = ""; + toast(plainError(code, text)); + renderStart(); + } + return; + } + toast(plainError(code, text)); +} + +// plainError says a relay's refusal in the page's own plain words, where +// there are some; the relay's words are for the people who run it. +function plainError(code, text) { + return { + "bad-code": WORDS.wrongCode, "too-many-tries": WORDS.tooManyTries, paused: WORDS.joinPaused, + "verify-failed": WORDS.signInRefused, "repo-not-served": WORDS.notThisProject, "no-session": WORDS.codeGone, + }[code] || text; +} + +// --------------------------------------------------------------------------- +// Yjs updates and awareness on the wire. +// --------------------------------------------------------------------------- +doc.on("update", (update, origin) => { + if (origin === FROM_RELAY) return; + if (session.role !== "member") return; // a guest's text never changes locally + if (!sendBinary(TAG_UPDATE, update)) outbox.push(update); + else keepRecent(update); +}); +function keepRecent(update) { + const now = Date.now(); + recentUpdates = recentUpdates.filter((u) => now - u.at < RESEND_WINDOW); + recentUpdates.push({ at: now, update }); +} +// resendRecent sends this page's recent updates again, merged into one, +// after the relay dropped one of them for coming too fast. +function resendRecent() { + clearTimeout(resendTimer); + resendTimer = setTimeout(() => { + const now = Date.now(); + const recent = recentUpdates.filter((u) => now - u.at < RESEND_WINDOW * 2).map((u) => u.update); + recentUpdates = []; + if (recent.length === 0 || session.role !== "member") return; + const merged = Y.mergeUpdates(recent); + if (sendBinary(TAG_UPDATE, merged)) keepRecent(merged); + else outbox.push(merged); + }, 1500); +} + +function shareAwareness() { + if (awareness.getLocalState() === null) return; + sendBinary(TAG_AWARENESS, encodeAwarenessUpdate(awareness, [doc.clientID])); +} +awareness.on("update", ({ added, updated, removed }, origin) => { + if (origin === FROM_RELAY) return; + const changed = added.concat(updated, removed); + if (!changed.includes(doc.clientID)) return; + sendBinary(TAG_AWARENESS, encodeAwarenessUpdate(awareness, [doc.clientID])); +}); + +// admitAwareness applies an awareness message the relay attributed to +// participant num, only when every client id in it is tied to that +// participant, with each cursor labelled from the roster: a page can claim +// any client id and any name in awareness, so neither is believed. Until a +// roster on this connection names the sender, the message is dropped; the +// others share their cursors again when the roster shows a newcomer. +function admitAwareness(num, body) { + if (!rosterSeen) return; + const sender = session.roster.find((p) => p.id === "p" + num); + if (!sender) return; + const label = cursorLabel(sender); + applyAwarenessUpdate(awareness, cleanAwarenessUpdate(body, (client) => (awarenessTied(sender, num, client) ? label : null)), FROM_RELAY); +} + +// guestAwareness ties each guest's participant number to the one awareness +// client id its first awareness message named. A guest's page registers no +// client id, so this is the only tie; it holds while the guest is in the +// roster, and a later message naming another client id is refused. +const guestAwareness = new Map(); + +// awarenessTied says whether participant num, the roster's sender, may speak +// for awareness client id client: a member for a client id the roster +// credits to their login, a guest for the client id tied to them, or, on +// their first message, one nobody is credited with or tied to. +function awarenessTied(sender, num, client) { + if (client === doc.clientID) return false; + if (sender.role === "member") return typeof sender.login === "string" && sender.login !== "" && creditedTo(client) === sender.login; + if (creditedTo(client) !== "") return false; + const tied = guestAwareness.get(num); + if (tied !== undefined) return tied === client; + for (const other of guestAwareness.values()) if (other === client) return false; + guestAwareness.set(num, client); + return true; +} + +// cursorLabel is the name a participant's cursor carries, from the roster: a +// member's login, or a guest's name as the relay stamped it, marked a guest. +function cursorLabel(sender) { + if (sender.role === "member" && typeof sender.login === "string" && sender.login) return cleanName(sender.login); + return cleanName(sender.name) + " (" + WORDS.guestCursor + ")"; +} + +// A remote awareness update is decoded, cleaned and encoded again before it +// is applied, so the editor never renders what another page chose for a +// colour or a name (security review F7). The encoding is y-protocols': a +// count, then per client its id, its clock and its state as JSON, numbers as +// lib0 variable-length unsigned integers and the JSON as a length and UTF-8. +function readVarUint(bytes, at) { + let num = 0; + let mult = 1; + for (;;) { + if (at.i >= bytes.length || mult > 2 ** 49) throw new Error("awareness"); + const b = bytes[at.i++]; + num += (b & 0x7f) * mult; + if (b < 0x80) return num; + mult *= 128; + } +} +function writeVarUint(out, num) { + while (num > 0x7f) { + out.push(0x80 | (num & 0x7f)); + num = Math.floor(num / 128); + } + out.push(num); +} +// cleanAwarenessUpdate is bytes cleaned, each state carrying the label +// labelFor gives its client id; labelFor's null, for a client id the sender +// may not speak for, drops the whole message. +function cleanAwarenessUpdate(bytes, labelFor) { + const at = { i: 0 }; + const out = []; + const count = readVarUint(bytes, at); + writeVarUint(out, count); + for (let n = 0; n < count; n++) { + const client = readVarUint(bytes, at); + const clock = readVarUint(bytes, at); + const len = readVarUint(bytes, at); + if (at.i + len > bytes.length) throw new Error("awareness"); + const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(at.i, at.i + len)); + at.i += len; + const label = labelFor(client); + if (label === null) throw new Error("awareness"); + const clean = new TextEncoder().encode(JSON.stringify(cleanAwarenessState(client, JSON.parse(json), label))); + writeVarUint(out, client); + writeVarUint(out, clock); + writeVarUint(out, clean.length); + for (const b of clean) out.push(b); + } + return Uint8Array.from(out); +} +// cleanAwarenessState keeps what the editor reads, a user and a cursor: the +// colours only when each is a six-digit hex colour (otherwise the palette's, +// or the editor's own derived light colour), and the name the roster gives +// the sender, whatever name the state claims. +function cleanAwarenessState(client, state, label) { + if (state === null || typeof state !== "object" || Array.isArray(state)) return null; + const u = state.user !== null && typeof state.user === "object" ? state.user : {}; + const user = { name: label, color: HEX_COLOUR_RE.test(u.color) ? u.color : COLOURS[client % COLOURS.length] }; + if (HEX_COLOUR_RE.test(u.colorLight)) user.colorLight = u.colorLight; + const c = state.cursor !== null && typeof state.cursor === "object" ? state.cursor : {}; + const anchor = cleanPosition(c.anchor); + const head = cleanPosition(c.head); + return { user, cursor: anchor && head ? { anchor, head } : null }; +} +// cleanPosition keeps a Yjs relative position's own fields, of their types. +// The editor sends a position as Yjs builds it, with null for a field it does +// not use (tname, or item at the end of a text): null is that field's absence. +function cleanPosition(p) { + if (p === null || typeof p !== "object") return null; + const id = (v) => (v !== null && typeof v === "object" && Number.isSafeInteger(v.client) && v.client >= 0 && + Number.isSafeInteger(v.clock) && v.clock >= 0 ? { client: v.client, clock: v.clock } : null); + const out = {}; + if (p.item != null) { out.item = id(p.item); if (!out.item) return null; } + if (p.type != null) { out.type = id(p.type); if (!out.type) return null; } + if (p.tname != null) { if (typeof p.tname !== "string") return null; out.tname = p.tname; } + if (p.assoc != null) { if (!Number.isSafeInteger(p.assoc)) return null; out.assoc = p.assoc; } + return out; +} +// cleanName is a participant's name as the editor shows it: no control or +// direction characters, at most 40 characters. +function cleanName(name) { + const s = (typeof name === "string" ? name : "").replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028-\u202e\u2066-\u2069]/g, "").trim(); + return [...s].slice(0, 40).join("") || "Guest"; +} +window.addEventListener("pagehide", () => { + removeAwarenessStates(awareness, [doc.clientID], "window unload"); +}); +function setAwarenessUser() { + const name = session.role === "member" && session.login ? session.login : (session.name || "Guest"); + awareness.setLocalStateField("user", { name, color: COLOURS[doc.clientID % COLOURS.length] }); +} + +// --------------------------------------------------------------------------- +// Sign-in through the team's GitHub App: GitHub's authorize page with PKCE, +// then the relay's exchange; or, on a local session's own page, GitHub's +// device flow through the relay. Nobody pastes a token, and the token stays +// in memory. +// --------------------------------------------------------------------------- +function b64url(bytes) { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function randomText(n) { + return b64url(crypto.getRandomValues(new Uint8Array(n))); +} +function isLoopbackOrigin(origin) { + try { + const h = new URL(origin).hostname; + return h === "localhost" || h === "127.0.0.1"; + } catch { return false; } +} + +async function relayConfig() { + const res = await fetch(session.relay + "/config", { + method: "GET", credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", + }); + if (!res.ok) throw new Error(WORDS.cannotConnect); + const cfg = await res.json(); + const clientID = typeof cfg.client_id === "string" ? cfg.client_id : ""; + let authorize = typeof cfg.authorize_url === "string" ? cfg.authorize_url : ""; + // Sign-in goes to github.com alone; a loopback relay may name a local + // stand-in for testing. + if (authorize !== GITHUB_AUTHORIZE && !(isLoopbackOrigin(session.relay) && isLoopbackOrigin(authorize))) authorize = ""; + // The relay offers GitHub's web flow when it holds the app's secret (a + // cloud relay), or its device flow when it holds the client id alone (a + // session on the host's own computer, where no secret may sit; + // adr-2610161400542916). The device flow is taken only on a page the relay + // serves itself: every local session serves its own page, so this page on + // any other origin, abcdesign.app above all, signs in through the web flow + // alone, and a relay naming the device flow there gets no sign-in. + const flow = cfg.flow === "device" ? (anchor.state === "self" ? "device" : "") : "web"; + return { + // Sign-in goes through a GitHub App alone: its user token can do only + // what both the app (its installations and fine-grained permissions) and + // the person may, so a relay a stranger's repository names cannot collect + // a token for the person's other repositories, and GitHub recommends Apps + // over OAuth apps + // (docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app, + // docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps). + // A relay that does not say github-app gets no sign-in at all: nobody + // pastes a token. The page takes the relay's word for the kind: GitHub + // documents no client-id format that tells a GitHub App's from an OAuth + // app's, and no public API answers for a client id, so the shape is not + // checked. The authorize request asks for no scope either way. + signIn: cfg.app_type === "github-app" && cfg.sign_in === true && /^[A-Za-z0-9._-]{1,100}$/.test(clientID) && (flow === "device" || (flow === "web" && authorize !== "")), + flow, + clientID, + authorize, + }; +} + +// startSignIn signs a team member in through the relay's sign-in: GitHub's +// web flow, which leaves the page and comes back, or its device flow, which +// shows a code to confirm on github.com. +async function startSignIn() { + if (framed()) return; + if (!session.relay) { toast(WORDS.notSetUp); return; } + if (!session.everJoined) readStartForm(); + // No sign-in starts through a relay the repository does not name, or, where + // its list cannot be read, one the person has not confirmed. GitHub then + // sends the code only to a callback the team's app lists. + if (!relayClearedFor("signin")) return; + let cfg; + try { cfg = await relayConfig(); } catch { toast(WORDS.cannotConnect); return; } + signInOffered = cfg.signIn; + if (!cfg.signIn) { + toast(WORDS.signInNotSetUp); + renderAll(); + return; + } + if (cfg.flow === "device" && anchor.state === "self") { startDeviceSignIn(); return; } + // GitHub returns the person to the relay's own callback, which the team's + // app lists; the relay sends them back to this page, naming itself. The + // state carries this page's address for that, after its random part. + const state = randomText(16) + "." + b64url(new TextEncoder().encode(pageURL())); + const verifier = randomText(32); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + const pending = { state, verifier, relay: session.relay, code: session.code, name: session.name }; + if (!stashSignIn(pending)) { + toast(WORDS.signInFailed); + return; + } + const q = new URLSearchParams({ + client_id: cfg.clientID, + redirect_uri: session.relay + CALLBACK_PATH, + state, + code_challenge: b64url(new Uint8Array(digest)), + code_challenge_method: "S256", + allow_signup: "false", + }); + // A GitHub App's authorization takes no scope. + location.assign(cfg.authorize + "?" + q.toString()); +} + +// finishSignIn handles the return from GitHub, which the relay's callback +// sends on to this page naming itself. The code goes to that relay alone: +// GitHub returned the person to it, through a callback the team's app lists. +// It never goes to a relay the invite link or this tab's storage names, and +// a return that names no relay, or another than the sign-in started with, is +// refused. +async function finishSignIn() { + const q = new URLSearchParams(location.search); + if (!q.has("state") || (!q.has("code") && !q.has("error"))) return false; + const code = q.get("code") || ""; + const state = q.get("state") || ""; + const returnedVia = relayOrigin(q.get("relay")); + const pending = takeSignIn(); + // The code and state come off the address bar before anything else. + history.replaceState(null, "", location.pathname + location.hash); + if (!pending || typeof pending.state !== "string" || pending.state !== state || !code) { + toast(WORDS.signInFailed); + return true; + } + if (!returnedVia || returnedVia !== relayOrigin(pending.relay)) { toast(WORDS.signInFailed); return true; } + session.relay = returnedVia; + session.code = normalizeCode(pending.code); + session.name = typeof pending.name === "string" ? pending.name : ""; + if (session.code) updateFragment(); + // GitHub's code goes to the relay's exchange only while the relay is still + // cleared for this repository. + if (!relayCleared()) { toast(standingRefusal(relayStanding(session.relay))); renderAll(); return true; } + setStatus(WORDS.signingIn, false); + try { + const res = await fetch(session.relay + "/auth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: pending.verifier, redirect_uri: session.relay + CALLBACK_PATH }), + credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", + }); + const out = await res.json().catch(() => ({})); + if (res.status === 429) { setStatus("", false); toast(WORDS.signInBusy); return true; } + if (!res.ok || typeof out.access_token !== "string" || out.access_token === "") throw new Error("exchange"); + heldToken = out.access_token; + tokenRelay = returnedVia; + } catch { + setStatus("", false); + toast(WORDS.signInFailed); + return true; + } + // Signed in from inside a session: back in as the same participant. + takeRejoin(); + await presentHeldToken(false); + return true; +} + +// --------------------------------------------------------------------------- +// GitHub's device flow, through the relay: GitHub documents no CORS for its +// device endpoints, and the relay keeps the device code, so the page holds +// only a handle. The person confirms the code on github.com in another tab; +// the page asks the relay after it, no faster than GitHub's interval. +// --------------------------------------------------------------------------- +let device = null; // { handle, relay, timer, until } +async function relayPost(path, body) { + const res = await fetch(session.relay + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", + }); + const out = await res.json().catch(() => ({})); + return { status: res.status, out: out && typeof out === "object" ? out : {} }; +} + +async function startDeviceSignIn() { + stopDeviceSignIn(true); + // A local session's own page alone signs in this way (see relayConfig). + if (anchor.state !== "self") { toast(WORDS.signInNotSetUp); return; } + let got; + try { got = await relayPost("/auth/device", {}); } catch { toast(WORDS.cannotConnect); return; } + const out = got.out; + if (got.status !== 200 || typeof out.handle !== "string" || !/^[A-Za-z0-9_-]{22}$/.test(out.handle) || + typeof out.user_code !== "string" || !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(out.user_code)) { + // Too many under way, from this address or on the relay: say so, rather + // than that the sign-in failed or is not set up. + if (got.status === 429 || out.error === "busy") toast(WORDS.signInBusy); + else toast(out.error === "device_flow_disabled" || got.status === 503 ? WORDS.signInNotSetUp : WORDS.signInFailed); + return; + } + const life = Math.min(Number(out.expires_in) || 900, 900) * 1000; + device = { handle: out.handle, relay: session.relay, timer: 0, until: Date.now() + life, interval: Math.max(5, Number(out.interval) || 5) }; + ui.deviceCode.textContent = out.user_code; + ui.device.hidden = false; + ui.deviceOpen.focus(); + pollDevice(); +} + +// stopDeviceSignIn stops asking after a device sign-in; with tell, the +// person stopped it (or left the page), so the relay is told to end it and +// free its place, rather than hold it until GitHub's code expires. +function stopDeviceSignIn(tell) { + const d = device; + if (d) clearTimeout(d.timer); + device = null; + if (ui.device) ui.device.hidden = true; + if (tell && d) { + fetch(d.relay + "/auth/device/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ handle: d.handle }), + credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", keepalive: true, + }).catch(() => { /* the relay drops it when GitHub's code expires */ }); + } +} + +function pollDevice() { + const d = device; + if (!d) return; + d.timer = setTimeout(async () => { + if (device !== d) return; + if (Date.now() > d.until || d.relay !== session.relay) { stopDeviceSignIn(); toast(WORDS.deviceExpired); return; } + let got; + try { got = await relayPost("/auth/device/token", { handle: d.handle }); } catch { got = null; } + if (device !== d) return; + const out = got ? got.out : {}; + if (got && got.status === 200 && typeof out.access_token === "string" && out.access_token !== "") { + stopDeviceSignIn(); + heldToken = out.access_token; + tokenRelay = d.relay; + takeRejoin(); + await presentHeldToken(false); + return; + } + if (!got || got.status >= 500 || out.error === "authorization_pending" || out.error === "slow_down") { + if (typeof out.interval === "number" && out.interval >= 5 && out.interval <= 60) d.interval = out.interval; + pollDevice(); + return; + } + stopDeviceSignIn(); + toast(out.error === "access_denied" ? WORDS.deviceDenied : out.error === "expired_token" || out.error === "token_expired" ? WORDS.deviceExpired : WORDS.signInFailed); + }, d.interval * 1000); +} + +// presentHeldToken gives the held token to the relay it was signed in +// through, and to no other, once that relay is cleared. With a token, the +// page first asks api.github.com alone for the repository's relay list, which +// a private repository shows only then: a list that does not name the relay +// refuses it, and forgets any confirmation. A relay that serves this page +// itself is the page's own origin and needs no list. +async function presentHeldToken(checked) { + if (!heldToken) return; + const relay = session.relay; + if (!relay || relay !== tokenRelay) { heldToken = ""; return; } + if (!checked && anchor.state !== "listed" && anchor.state !== "self") { + const seen = await readRelayList(heldToken); + // A list the token shows decides. One GitHub would not show for a reason + // other than its absence refuses the relay until it can be checked. + if (seen.state === "listed" || seen.state === "unavailable") anchor = seen; + } + if (!heldToken || relay !== session.relay) { heldToken = ""; return; } + const standing = relayStanding(relay); + if (standing === "refused" || standing === "unavailable") { + if (standing === "refused") forgetRelay(relay); + heldToken = ""; + toast(standingRefusal(standing)); + renderAll(); + return; + } + if (standing === "unconfirmed") { askTrust("token"); return; } + if (standing !== "listed" && standing !== "confirmed" && standing !== "self") { heldToken = ""; return; } + githubToken = heldToken; + heldToken = ""; + connectSignedIn(); +} + +// connectSignedIn presents the token: on the open connection when there is +// one, or on a new one. +function connectSignedIn() { + if (!githubToken || !relayTokenCleared()) { githubToken = ""; return; } + setStatus(WORDS.signingIn, false); + if (socketOpen()) { + session.verifying = !session.inRoom; + sendJSON(session.inRoom ? { type: "verify", token: githubToken } : { type: "verify", token: githubToken, repo: route.slug }); + } else { + openSocket(); + } + renderAll(); +} + +// --------------------------------------------------------------------------- +// Which relay, and whether it may have anything (security review F1). The +// page never shows or asks for a relay address (adr-2610161400542916). A page +// the relay serves itself takes its own origin. Otherwise a link can name any +// relay in its fragment, so the repository is the trust anchor: it names its +// relays in a committed file the page reads from api.github.com, without a +// token for a public repository. Where that file cannot be read, the person +// confirms, in plain words, that the project's team sent the link, before any +// sign-in, name or suggestion reaches the relay. +// --------------------------------------------------------------------------- +// The anchor: "self" when the relay serves this page; "checking" until GitHub +// answers; "listed" with the relays the file names (none, for a file that +// names none the page can use); "unknown" when GitHub says there is no file it +// will show (404: none, or a private repository); or "unavailable" when GitHub +// did not answer that (a 403, a 429, a 5xx, a network error): then the list +// could not be checked, and nothing reaches the relay, confirmed or not, +// until it can be. +let anchor = { state: "checking", relays: [] }; + +// selfServed says the relay serves this page itself, a local session: its +// HTML then carries the marker, which the static page never does. +function selfServed() { + const m = document.querySelector('meta[name="abcd-session-relay"]'); + return m !== null && m.getAttribute("content") === "self"; +} + +// readRelayList reads the repository's relay list from its default branch, +// with the token given, or none. +async function readRelayList(token) { + const headers = { Accept: "application/vnd.github+json" }; + if (token) headers.Authorization = "Bearer " + token; + const stop = new AbortController(); + const timer = setTimeout(() => stop.abort(), 10000); + try { + const res = await fetch(GITHUB_API + repoPath() + "/contents/" + RELAYS_FILE, { + headers, signal: stop.signal, credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", + }); + if (res.status === 404) return { state: "unknown", relays: [] }; + if (res.status !== 200) return { state: "unavailable", relays: [] }; + return { state: "listed", relays: relaysIn(await res.json()) }; + } catch { + return { state: "unavailable", relays: [] }; + } finally { + clearTimeout(timer); + } +} + +// relaysIn reads the relays a contents answer names: each exactly an origin, +// https (or http on this computer, as the page takes a local relay). A file +// that does not parse names none, so it refuses every relay. +function relaysIn(body) { + try { + if (!body || body.type !== "file" || body.encoding !== "base64" || typeof body.content !== "string" || body.content.length > 65536) return []; + const list = JSON.parse(decodeBlob(body.content)); + if (!list || !Array.isArray(list.relays)) return []; + const out = []; + for (const r of list.relays.slice(0, 32)) { + if (typeof r !== "string") continue; + let u; + try { u = new URL(r); } catch { continue; } + const origin = relayOrigin(r); + if (origin && origin === u.origin && u.pathname === "/" && !u.search && !u.hash) out.push(origin); + } + return out; + } catch { return []; } +} + +// chooseRelay settles which relay this page uses, once the anchor is read: a +// page the relay serves uses its own origin; otherwise the relay the link +// names, or, with none, the first the repository's list names, or, where the +// list cannot be read, the one the person confirmed for this repository. +function chooseRelay(fromLink) { + if (anchor.state === "self") return location.origin; + if (fromLink) return fromLink; + if (anchor.state === "listed") return anchor.relays[0] || ""; + if (anchor.state === "unknown") return confirmedRelays()[0] || ""; + return ""; +} + +// relayStanding is what the page may do with a relay: "self", "listed" or +// "confirmed" may have a token, "unconfirmed" must be confirmed first, +// "refused" and "unavailable" get nothing and no confirmation is offered for +// them, "checking" waits for GitHub's answer. +function relayStanding(relay) { + if (!relay) return ""; + if (anchor.state === "self") return relay === location.origin ? "self" : "refused"; + if (anchor.state === "checking") return "checking"; + if (anchor.state === "unavailable") return "unavailable"; + if (anchor.state === "listed") return anchor.relays.includes(relay) ? "listed" : "refused"; + return confirmedRelays().includes(relay) ? "confirmed" : "unconfirmed"; +} +function relayCleared() { + const standing = relayStanding(session.relay); + return standing === "self" || standing === "listed" || standing === "confirmed"; +} +// relayTokenCleared says the token may go to the relay: it is cleared, and +// it is the relay the token was signed in through. A relay an invite link +// names, which GitHub did not return the person to, never has the token. +function relayTokenCleared() { + return relayCleared() && session.relay !== "" && session.relay === tokenRelay; +} +// standingRefusal says, in plain words and with no address, why a relay that +// is not cleared gets nothing. +function standingRefusal(standing) { + if (standing === "checking") return WORDS.checking; + if (standing === "unavailable") return WORDS.cannotCheck; + if (standing === "refused") return WORDS.notOurLink; + return WORDS.notSetUp; +} + +// recheckRelayList reads the relay list again after GitHub did not answer, +// so the person can try again without reloading. +let rechecking = false; +async function recheckRelayList() { + if (rechecking || anchor.state !== "unavailable") return; + rechecking = true; + try { + const seen = await readRelayList(""); + if (anchor.state === "unavailable") anchor = seen; + if (!session.relay) session.relay = chooseRelay(linkRelay); + } finally { + rechecking = false; + renderAll(); + refreshSignIn(); + } +} + +// relayClearedFor reports whether the relay is cleared; when it is not, it +// asks the person to confirm it (for a relay the list cannot speak for) or +// says why it is refused. +function relayClearedFor(action) { + const standing = relayStanding(session.relay); + if (standing === "self" || standing === "listed" || standing === "confirmed") return true; + if (standing === "unconfirmed") askTrust(action); + else { + heldToken = ""; + toast(standingRefusal(standing)); + if (standing === "unavailable") recheckRelayList(); + } + renderAll(); + return false; +} + +let trustPending = null; +function askTrust(action) { + if (framed()) return; + trustPending = { action, relay: session.relay }; + // The project this page is for, shown so a link to a stranger's project is + // seen for what it is. No address is shown: the question is whether the + // project's team sent the link. + clear(ui.trustOwner); + ui.trustOwner.append(WORDS.pageFor + " ", el("strong", { text: route.slug }), "."); + noteOpener("trust"); + ui.trust.hidden = false; + ui.trustCancel.focus(); +} +function trustAnswered(yes) { + const p = trustPending; + trustPending = null; + ui.trust.hidden = true; + returnFocus("trust"); + if (!p) return; + if (!yes || p.relay !== session.relay || relayStanding(p.relay) !== "unconfirmed") { + heldToken = ""; + renderAll(); + return; + } + confirmRelay(p.relay); + renderAll(); + refreshSignIn(); + if (p.action === "signin") startSignIn(); + else if (p.action === "token") presentHeldToken(true); + else if (p.action === "join") joinClicked(); + else if (p.action === "host") hostClicked(); +} + +// ownerNotice names the repository beside the sign-in, prominently: a link +// can open the page for any repository, a stranger's among them, and that +// repository's list then names the stranger's relay. +function ownerNotice() { + return el("span", { class: "owner" }, WORDS.signingInTo + " ", el("strong", { text: route.slug }), "."); +} + +// signInOffered is what the relay's /config said: true for a GitHub App's +// sign-in, false for none, null before a cleared relay was asked. Only false +// takes the sign-in buttons away; before the relay is asked, signing in is +// what confirms it. +let signInOffered = null; +async function refreshSignIn() { + if (!relayCleared()) return; + let offered = false; + try { offered = (await relayConfig()).signIn; } catch { offered = false; } + signInOffered = offered; + renderStart(); + renderTop(); +} + +// --------------------------------------------------------------------------- +// Hosting: read the brief through GitHub's API, seed the document once, then +// show the invite. +// --------------------------------------------------------------------------- +async function gh(path) { + const res = await fetch(GITHUB_API + path, { + headers: { Accept: "application/vnd.github+json", Authorization: "Bearer " + githubToken }, + credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", + }); + if (!res.ok) throw new Error(WORDS.githubFailed + " (" + res.status + ")."); + return res.json(); +} +const SHA_RE = /^[0-9a-f]{40,64}$/; +function repoPath() { + return "/repos/" + encodeURIComponent(route.owner) + "/" + encodeURIComponent(route.repo); +} +async function subtree(sha, name, recursive) { + if (!SHA_RE.test(sha)) throw new Error(WORDS.noBrief); + const t = await gh(repoPath() + "/git/trees/" + sha + (recursive ? "?recursive=1" : "")); + if (!t || !Array.isArray(t.tree)) throw new Error(WORDS.noBrief); + if (recursive && t.truncated) throw new Error(WORDS.noBrief); + if (name === null) return t; + const entry = t.tree.find((e) => e && e.path === name && e.type === "tree"); + if (!entry) throw new Error(WORDS.noBrief); + return entry.sha; +} +function decodeBlob(b64) { + const bin = atob(String(b64).replace(/\s/g, "")); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); +} +async function loadBrief() { + const repo = await gh(repoPath()); + const branch = typeof repo.default_branch === "string" ? repo.default_branch : ""; + if (!branch) throw new Error(WORDS.noBrief); + const head = await gh(repoPath() + "/branches/" + encodeURIComponent(branch)); + const sha = head && head.commit && head.commit.sha; + const rootTree = head && head.commit && head.commit.commit && head.commit.commit.tree && head.commit.commit.tree.sha; + if (!SHA_RE.test(sha || "") || !SHA_RE.test(rootTree || "")) throw new Error(WORDS.noBrief); + let treeSha = rootTree; + for (const name of BRIEF_DIR) treeSha = await subtree(treeSha, name, false); + const brief = await subtree(treeSha, null, true); + const blobs = brief.tree.filter((e) => e && e.type === "blob" && typeof e.path === "string" && e.path.endsWith(".md") && SHA_RE.test(e.sha || "")); + if (blobs.length === 0) throw new Error(WORDS.noBrief); + const out = new Array(blobs.length); + let next = 0; + async function worker() { + while (next < blobs.length) { + const i = next++; + const b = await gh(repoPath() + "/git/blobs/" + blobs[i].sha); + if (!b || b.encoding !== "base64") throw new Error(WORDS.noBrief); + out[i] = { path: BRIEF_PREFIX + blobs[i].path, text: decodeBlob(b.content) }; + } + } + await Promise.all([worker(), worker(), worker(), worker(), worker(), worker()]); + out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + return { sha, branch, files: out }; +} + +function utf8Length(s) { + return new TextEncoder().encode(s).length; +} +function pieces(text, size) { + const out = []; + let i = 0; + while (i < text.length) { + let end = Math.min(text.length, i + size); + // Never split a surrogate pair. + if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1; + out.push(text.slice(i, end)); + i = end; + } + return out; +} + +// seed fills the document from the brief, exactly once. Each transaction is +// one update the relay logs, kept under its message size. +function seed(brief, mode) { + if (files.size > 0 || meta.has("base_sha")) return; + let batch = []; + let bytes = 0; + const flush = () => { + if (batch.length === 0) return; + const work = batch; + batch = []; + bytes = 0; + doc.transact(() => { for (const f of work) files.set(f.path, new Y.Text(f.text)); }, FROM_SEED); + }; + for (const f of brief.files) { + const size = utf8Length(f.text); + if (size <= SEED_BUDGET) { + if (bytes + size > SEED_BUDGET) flush(); + batch.push(f); + bytes += size; + continue; + } + flush(); + const text = new Y.Text(); + doc.transact(() => { files.set(f.path, text); }, FROM_SEED); + for (const p of pieces(f.text, SEED_PIECE)) doc.transact(() => { text.insert(text.length, p); }, FROM_SEED); + } + flush(); + const base = Y.encodeSnapshot(Y.snapshot(doc)); + doc.transact(() => { + meta.set("repo", route.slug); + meta.set("base_sha", brief.sha); + meta.set("branch", brief.branch); + meta.set("mode", mode === "issues" ? "issues" : "pr"); + meta.set("base_snapshot", base); + }, FROM_SEED); +} + +async function drained() { + for (let i = 0; i < 600 && socketOpen() && ws.bufferedAmount > 0; i++) { + await new Promise((r) => setTimeout(r, 50)); + } +} + +async function hostBrief() { + setStatus(WORDS.loadingBrief, false); + const brief = await loadBrief(); + setStatus(WORDS.sendingBrief, false); + seed(brief, ui.mode ? ui.mode.value : "pr"); + trackDeletions(); + await drained(); + session.seeding = false; + session.synced = true; + updateFragment(); + setStatus(WORDS.connected, false); + renderAll(); +} + +// inviteFragment is what the invite link carries after #: the code, and the +// relay only when a guest's page could not find the same one itself. A page +// the relay serves is its own relay, and the first relay the repository's +// list names is the one a page finds with no relay in the link. +function inviteFragment() { + const own = anchor.state === "self" || (anchor.state === "listed" && anchor.relays[0] === session.relay); + return (own ? "" : "relay=" + encodeURIComponent(session.relay) + "&") + "code=" + session.code; +} +function inviteURL() { + return location.origin + route.base + "/" + encodeURIComponent(route.owner) + "/" + encodeURIComponent(route.repo) + + "/brief/#" + inviteFragment(); +} +function updateFragment() { + if (!session.code || !session.relay) return; + history.replaceState(null, "", location.pathname + "#" + inviteFragment()); +} + +// --------------------------------------------------------------------------- +// Guests' suggestions, and the members' answers to them. +// --------------------------------------------------------------------------- +function quoteOf(text, from, to) { + return { + exact: text.slice(from, to), + prefix: text.slice(Math.max(0, from - QUOTE_CONTEXT), from), + suffix: text.slice(to, to + QUOTE_CONTEXT), + }; +} + +// sendSuggestion sends a guest's suggestion and reports whether it went. +function sendSuggestion(replacement) { + if (!view || !currentPath) return false; + const ytext = files.get(currentPath); + const sel = view.state.selection.main; + if (!(ytext instanceof Y.Text) || sel.empty) { toast(WORDS.selectFirst); return false; } + if (replacement.length > MAX_REPLACEMENT || sel.to - sel.from > MAX_REPLACEMENT) { toast(WORDS.suggestTooBig); return false; } + const text = ytext.toString(); + const body = { + path: currentPath, + anchor: Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(ytext, sel.from)), + head: Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(ytext, sel.to)), + quote: quoteOf(text, sel.from, sel.to), + replacement, + }; + // The relay refuses a suggestion over its size; say so here instead. + if (utf8Length(JSON.stringify(body)) > MAX_SUGGEST_BYTES) { toast(WORDS.suggestTooBig); return false; } + if (suggestionInFlight) { toast(WORDS.suggestWait); return false; } + suggestionInFlight = body; + clearTimeout(suggestTimer); + suggestTimer = setTimeout(suggestionTimedOut, SUGGEST_TIMEOUT); + sendJSON({ type: "suggest", body }); + return true; +} + +// suggestionTimedOut gives up on a suggestion the relay has not answered: +// the guest is told it may not have reached the team. +function suggestionTimedOut() { + if (!suggestionInFlight) return; + suggestionInFlight = null; + toast(WORDS.suggestLost); + renderSide(); +} + +function onSuggested(msg) { + const body = suggestionInFlight; + suggestionInFlight = null; + clearTimeout(suggestTimer); + if (!body) return; + mine.push({ id: String(msg.id || ""), body }); + toast(msg.delivered > 0 ? WORDS.suggestionSent : WORDS.noMembers); + renderSide(); +} + +// validSuggestion checks a suggestion's fields, each of its type and bounded: +// a path among the brief's files the session started with, a passage and a +// replacement of at most MAX_REPLACEMENT characters, and the passage's +// context at most MAX_QUOTE_PART each side. +function validSuggestion(b) { + if (!b || typeof b !== "object" || !b.quote || typeof b.quote !== "object") return false; + if (typeof b.path !== "string" || typeof b.replacement !== "string" || typeof b.quote.exact !== "string" || + typeof b.quote.prefix !== "string" || typeof b.quote.suffix !== "string") return false; + const base = baseSnapshot(); + return b.quote.exact !== "" && b.quote.exact.length <= MAX_REPLACEMENT && b.replacement.length <= MAX_REPLACEMENT && + b.quote.prefix.length <= MAX_QUOTE_PART && b.quote.suffix.length <= MAX_QUOTE_PART && + briefFile(b.path, baseFilePaths(base)); +} + +// boundedPosition is a relative position as the page keeps it: its own +// fields, of their types, at most MAX_POSITION_JSON as JSON, or null. +function boundedPosition(p) { + const clean = cleanPosition(p); + return clean && JSON.stringify(clean).length <= MAX_POSITION_JSON ? clean : null; +} + +// storedSuggestion is what a member's page records of a guest's suggestion: +// the fields it is made of, each validated and bounded, and nothing else; or +// null for one that is not a suggestion, or is larger than the relay passes. +// The relay caps the body alone, so the body alone is measured: the id and +// the name added here must not push a suggestion it passed over the cap. +function storedSuggestion(id, from, b) { + if (!SUGGESTION_ID_RE.test(id) || !validSuggestion(b)) return null; + const quote = { exact: b.quote.exact, prefix: b.quote.prefix, suffix: b.quote.suffix }; + const out = { + id, from: cleanName(from), + body: { path: b.path, anchor: boundedPosition(b.anchor), head: boundedPosition(b.head), quote, replacement: b.replacement }, + }; + return utf8Length(JSON.stringify(out.body)) > MAX_SUGGEST_BYTES ? null : out; +} + +function onSuggest(msg) { + if (session.role !== "member") return; + const id = String(msg.id || ""); + const from = typeof msg.from === "string" && msg.from ? msg.from : "Guest"; + const stored = storedSuggestion(id, from, msg.body); + if (!stored) return; + // Every member's page sees the suggestion; the first to record it wins, and + // the others find it there already. + if (!suggestions.has(id) && !suggestionsDone.has(id)) { + doc.transact(() => { suggestions.set(id, stored); }); + } + renderSide(); +} + +// findQuote places a W3C TextQuoteSelector: the occurrence of exact whose +// surroundings match the prefix and suffix best, or -1 when that is not one. +function findQuote(text, q) { + const at = []; + for (let i = text.indexOf(q.exact); i !== -1; i = text.indexOf(q.exact, i + 1)) at.push(i); + if (at.length === 0) return -1; + if (at.length === 1) return at[0]; + const prefix = q.prefix || ""; + const suffix = q.suffix || ""; + const score = (i) => { + let s = 0; + const before = text.slice(Math.max(0, i - prefix.length), i); + const after = text.slice(i + q.exact.length, i + q.exact.length + suffix.length); + while (s < prefix.length && before[before.length - 1 - s] === prefix[prefix.length - 1 - s]) s++; + let t = 0; + while (t < suffix.length && after[t] === suffix[t]) t++; + return s + t; + }; + const scored = at.map((i) => [score(i), i]).sort((a, b) => b[0] - a[0]); + return scored[0][0] > scored[1][0] ? scored[0][1] : -1; +} + +// locate finds the suggested passage now: its relative positions first, then +// its quote; null when neither still points at the quoted text. +function locate(body) { + const ytext = files.get(body.path); + if (!(ytext instanceof Y.Text)) return null; + const text = ytext.toString(); + try { + const a = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(body.anchor), doc); + const h = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(body.head), doc); + if (a && h && a.type === ytext && h.type === ytext) { + const from = Math.min(a.index, h.index); + const to = Math.max(a.index, h.index); + if (text.slice(from, to) === body.quote.exact) return { ytext, from, to }; + } + } catch { /* fall through to the quote */ } + const at = findQuote(text, body.quote); + return at < 0 ? null : { ytext, from: at, to: at + body.quote.exact.length }; +} + +function applySuggestion(id) { + const s = suggestions.get(id); + if (!s || !validSuggestion(s.body) || session.role !== "member" || suggestionsDone.has(id)) return; + const place = locate(s.body); + if (!place) { toast(WORDS.cannotApply); return; } + const replacement = s.body.replacement; + doc.transact((tr) => { + const before = place.ytext.toString().slice(place.from, place.to); + place.ytext.delete(place.from, place.to - place.from); + // The ids of the text the approval removed and inserted, so the credit + // can tell an approved change from the approver's own typing. + const del = []; + tr.deleteSet.clients.forEach((items, client) => { for (const d of items) del.push([client, d.clock, d.len]); }); + const clock = Y.getState(doc.store, doc.clientID); + place.ytext.insert(place.from, replacement); + approvals.push([{ + id, + path: s.body.path, + guest: typeof s.from === "string" ? s.from : "Guest", + approver: session.login, + client: doc.clientID, + ins: replacement ? [doc.clientID, clock, replacement.length] : null, + del, + before, + after: replacement, + at: new Date().toISOString(), + }]); + suggestionsDone.set(id, "applied"); + }); + renderSide(); +} + +function declineSuggestion(id) { + if (session.role !== "member") return; + if (!suggestionsDone.has(id)) doc.transact(() => { suggestionsDone.set(id, "declined"); }); + renderSide(); +} +suggestions.observe(() => renderSide()); +suggestionsDone.observe(() => renderSide()); + +// --------------------------------------------------------------------------- +// Credit: what changed since the session began, and who changed it. +// +// The base is the snapshot the host took right after seeding. Y.Text's +// toDelta(now, base) marks each piece of text added or removed since then, +// with the Yjs item it belongs to; the item's client id names who inserted +// it, and the members' recorded deletions name who removed it. The relay's +// roster maps each member's client ids to their GitHub login. +// --------------------------------------------------------------------------- +let deletionsTracked = false; +function trackDeletions() { + if (deletionsTracked) return; + deletionsTracked = true; + deletions.setUserMapping(doc, doc.clientID, String(doc.clientID), { filter: (tr) => touchesBrief(tr) }); +} +function touchesBrief(tr) { + const texts = new Set(files.values()); + for (const type of tr.changed.keys()) if (texts.has(type)) return true; + return false; +} + +// --------------------------------------------------------------------------- +// The session's settings. Any member's page can write the shared meta map, +// for instance to point the pull request at another branch, so a value counts +// only when the Yjs item holding it was written by a client id the roster +// credits to the host's login. The base, the branch and the repository are +// written once, when the host's page seeds the session: the host's first +// write of each counts and every later write is void. The mode may change, +// but only from the host: the host's latest write counts. gc is off, so an +// overwritten value's item keeps its content and its writer. +// --------------------------------------------------------------------------- +const META_ONCE = ["base_snapshot", "base_sha", "branch", "repo"]; +const META_KEYS = META_ONCE.concat(["mode"]); + +// effectiveMeta is the settings as the host wrote them, and in voided each +// void write as { key, client, clock }. A write whose writer the roster does +// not credit yet (this page's own, before the roster names it) is neither +// counted nor void. Without the host's login, nothing is known. +function effectiveMeta() { + const out = { voided: [] }; + const host = session.hostLogin; + if (!host) return out; + for (const key of META_KEYS) { + // The key's items, oldest first: each new value's item goes to the right + // of the one it replaced. + const items = []; + for (let item = meta._map.get(key); item; item = item.left) items.unshift(item); + let chosen = null; + for (const item of items) { + const writer = creditedTo(item.id.client); + if (writer === host && (chosen === null || !META_ONCE.includes(key))) chosen = item; + else if (writer !== "") out.voided.push({ key, client: item.id.client, clock: item.id.clock }); + } + if (chosen) out[key] = chosen.content.getContent()[0]; + } + return out; +} + +// watchMeta warns once for each void write to the settings it has not seen. +const voidSeen = new Set(); +function watchMeta() { + for (const v of effectiveMeta().voided) { + const id = v.client + ":" + v.clock; + if (voidSeen.has(id)) continue; + voidSeen.add(id); + const who = loginOf(v.client); + warn(WORDS.metaVoid + " " + (who ? "@" + who : WORDS.someone) + "."); + } +} +meta.observe(() => { + watchMeta(); + renderSide(); +}); + +let baseCache = { raw: null, snap: null }; +function baseSnapshot() { + const raw = effectiveMeta().base_snapshot; + if (!(raw instanceof Uint8Array)) return null; + if (baseCache.raw !== raw) { + try { baseCache = { raw, snap: Y.decodeSnapshot(raw) }; } catch { return null; } + } + return baseCache.snap; +} + +// creditedTo is the login the roster credits a Yjs client id to, or "" for +// one it does not, or one an unattributed update carried. +function creditedTo(client) { + if (unattributed.has(Number(client))) return ""; + const login = session.credits[String(client)]; + return typeof login === "string" ? login : ""; +} +function loginOf(client) { + const login = creditedTo(client); + return NAME_RE.test(login) ? login : ""; +} + +// verifiedApprovals is the approvals each written by its own approver: the +// Yjs client that wrote the entry is the one it names, credited to the login +// it names, and the text it says it inserted is that client's. Any member's +// page can push to the array, so an entry naming someone else counts for +// nobody (security review F6). +function verifiedApprovals() { + const out = []; + for (let item = approvals._start; item !== null; item = item.right) { + if (item.deleted) continue; + const writer = item.id.client; + for (const a of item.content.getContent()) { + if (!a || typeof a !== "object" || a.client !== writer || !loginOf(writer) || loginOf(writer) !== a.approver) continue; + if (a.ins !== null && !(Array.isArray(a.ins) && a.ins[0] === writer)) continue; + out.push(a); + } + } + return out; +} + +// verifiedDeleters is the members' deletion records each written by the +// member it is filed under: the record's key is a client id, and every Yjs +// client that wrote the record's entry and its contents must be credited to +// that client's login. +function verifiedDeleters() { + const out = []; + const users = doc.getMap("deletions"); + for (const [desc, ds] of deletions.dss) { + const login = loginOf(desc); + const entry = users._map.get(desc); + const user = users.get(desc); + if (!login || !entry || !(user instanceof Y.Map)) continue; + const writers = [entry.id.client]; + for (const key of ["ids", "ds"]) { + const list = user.get(key); + if (!(list instanceof Y.Array)) continue; + for (let item = list._start; item !== null; item = item.right) writers.push(item.id.client); + } + if (writers.every((c) => loginOf(c) === login)) out.push([desc, ds]); + } + return out; +} + +// changesOf lists a file's text as runs: "same" since the base, "add" or +// "del", each changed run one Yjs item with its client and clock. +function changesOf(ytext, base) { + const delta = ytext.toDelta(Y.snapshot(doc), base, (type, id) => ({ type, client: id.client, clock: id.clock })); + const ops = []; + for (const op of delta) { + if (typeof op.insert !== "string") continue; + const ch = op.attributes && op.attributes.ychange; + if (!ch) ops.push({ kind: "same", text: op.insert }); + else ops.push({ kind: ch.type === "added" ? "add" : "del", text: op.insert, client: ch.client, clock: ch.clock }); + } + return ops; +} + +function newlines(s) { + let n = 0; + for (let i = s.indexOf("\n"); i !== -1; i = s.indexOf("\n", i + 1)) n++; + return n; +} + +function overlaps(clock, len, from, n) { + return clock < from + n && from < clock + len; +} + +// approvalKey tells approvals apart: each approved guest change by its own +// id, so two changes by the same guest and approver stay two. +function approvalKey(a) { + return typeof a.id === "string" && a.id ? a.id : a; +} + +// approvalOf names the approval a changed run belongs to, if any. +function approvalOf(path, op, list) { + for (const a of list) { + if (!a || a.path !== path) continue; + if (op.kind === "add" && Array.isArray(a.ins) && a.ins[0] === op.client && overlaps(op.clock, op.text.length, a.ins[1], a.ins[2])) return a; + if (op.kind === "del" && Array.isArray(a.del) && a.del.some((d) => Array.isArray(d) && d[0] === op.client && overlaps(op.clock, op.text.length, d[1], d[2]))) return a; + } + return null; +} + +// deletersOf names the members whose pages recorded deleting a removed run. +function deletersOf(op) { + const out = []; + for (const [client, ds] of verifiedDeleters()) { + const ranges = ds.clients.get(op.client) || []; + if (ranges.some((r) => overlaps(op.clock, op.text.length, r.clock, r.len))) out.push(client); + } + return out; +} + +// makePassage turns the changed runs ops[i..j] into a passage: whole lines +// before and after, where they sit in the base, and who to credit. +function makePassage(path, ops, offsets, i, j, approvalList) { + const prev = i > 0 ? ops[i - 1].text : ""; + const left = prev.slice(prev.lastIndexOf("\n") + 1); + let before = left; + let after = left; + const authors = new Set(); + const approved = new Map(); + let unknown = false; + for (let k = i; k <= j; k++) { + const op = ops[k]; + if (op.kind !== "del") after += op.text; + if (op.kind !== "add") before += op.text; + if (op.kind === "same") continue; + const a = approvalOf(path, op, approvalList); + if (a) { + const approver = loginOf(a.client) || (typeof a.approver === "string" ? a.approver : ""); + const guest = trailerText(a.guest); + approved.set(approvalKey(a), { key: approvalKey(a), guest, approver }); + continue; + } + const who = op.kind === "add" ? [op.client] : deletersOf(op); + let named = false; + for (const c of who) { + const login = loginOf(c); + if (login) { authors.add(login); named = true; } + } + if (!named) unknown = true; + } + const lineEnded = (s) => s === "" || s.endsWith("\n"); + if (j + 1 < ops.length && !(lineEnded(before) && lineEnded(after))) { + const next = ops[j + 1].text; + const cut = next.indexOf("\n"); + const right = cut < 0 ? next : next.slice(0, cut); + before += right; + after += right; + } + const baseStart = offsets[i].base - left.length; + return { + path, + baseStart, + baseEnd: baseStart + before.length, + before, + after, + authors: [...authors].sort(), + approvals: [...approved.values()], + unknown, + }; +} + +// baseFilePaths names the files the session started with: the files map's +// keys as the base snapshot saw them, or null without a base. +function baseFilePaths(base) { + if (!base) return null; + try { return new Set(Object.keys(Y.typeMapGetAllSnapshot(files, base))); } catch { return null; } +} + +// briefFile says whether a path may go back to GitHub: a Markdown file under +// the brief, with no ".." and no backslash, that the session started with. +// The paths are the shared files map's keys, which any team member's page +// can write, so nothing else is shown or sent (security review F9). +function briefFile(path, baseFiles) { + return typeof path === "string" && path.startsWith(BRIEF_PREFIX) && path.endsWith(".md") && + !path.includes("..") && !path.includes("\\") && baseFiles instanceof Set && baseFiles.has(path); +} + +// shownPath is a refused path as the page names it: one line, bounded. +function shownPath(path) { + return String(path).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?").slice(0, 200); +} + +// analyse reads every file's changes since the base, as passages. A path +// outside the brief is left out, and named in the result's refused list. +function analyse() { + const base = baseSnapshot(); + const out = []; + out.refused = []; + if (!base) return out; + const baseFiles = baseFilePaths(base); + const refused = out.refused; + const approvalList = verifiedApprovals(); + for (const path of [...files.keys()].sort()) { + if (!briefFile(path, baseFiles)) { + refused.push(path); + continue; + } + const ytext = files.get(path); + if (!(ytext instanceof Y.Text)) continue; + const ops = changesOf(ytext, base); + if (!ops.some((op) => op.kind !== "same")) continue; + const offsets = []; + let b = 0; + let c = 0; + for (const op of ops) { + offsets.push({ base: b, cur: c }); + if (op.kind !== "add") b += op.text.length; + if (op.kind !== "del") c += op.text.length; + } + const baseText = ops.filter((op) => op.kind !== "add").map((op) => op.text).join(""); + const passages = []; + let i = 0; + while (i < ops.length) { + if (ops[i].kind === "same") { i++; continue; } + let j = i; + let k = i + 1; + for (;;) { + while (k < ops.length && ops[k].kind !== "same") { j = k; k++; } + // ops[k] is unchanged text; one more changed run after it, within + // PASSAGE_JOIN_LINES unchanged lines, joins this passage. + if (k + 1 < ops.length && newlines(ops[k].text) <= PASSAGE_JOIN_LINES + 1) { j = k + 1; k += 2; continue; } + break; + } + const p = makePassage(path, ops, offsets, i, j, approvalList); + p.lineFrom = 1 + newlines(baseText.slice(0, p.baseStart)); + p.lineTo = p.lineFrom + Math.max(0, newlines(p.before.replace(/\n$/, ""))); + passages.push(p); + i = j + 1; + } + const current = ops.filter((op) => op.kind !== "del").map((op) => op.text).join(""); + if (current !== baseText) out.push({ path, base: baseText, current, passages }); + } + return out; +} + +function relPath(path) { + return path.startsWith(BRIEF_PREFIX) ? path.slice(BRIEF_PREFIX.length) : path; +} +function linesLabel(p) { + return p.lineTo > p.lineFrom ? WORDS.linesAt + " " + p.lineFrom + "–" + p.lineTo : WORDS.lineAt + " " + p.lineFrom; +} + +// creditLines says who a passage is credited to, one line each. +// show formats a guest's name for where the lines go: as it is for the page, +// mdName for an issue or a pull request body. +function creditLines(p, show = (s) => s) { + const lines = []; + if (p.authors.length) lines.push("Written by " + p.authors.map((l) => "@" + l).join(", ")); + for (const a of p.approvals) lines.push("Suggested by " + show(a.guest) + " (guest), approved by @" + a.approver); + if (p.unknown && lines.length === 0) lines.push(WORDS.creditUnknown); + return lines; +} + +// --------------------------------------------------------------------------- +// The issues route: one pre-filled GitHub issue link per passage. +// --------------------------------------------------------------------------- +// fenced wraps text in a code fence longer than any run of backticks in it. +function fenced(text) { + let longest = 0; + for (const m of text.matchAll(/`+/g)) longest = Math.max(longest, m[0].length); + const fence = "`".repeat(Math.max(3, longest + 1)); + return fence + "markdown\n" + text + (text === "" || text.endsWith("\n") ? "" : "\n") + fence; +} + +// ISSUE_MARKER ends every issue a session sends, so the team's agent can find +// them without a label: a pre-filled label is refused for anyone who may not +// triage the repository, and the repository may not have one. +const ISSUE_MARKER = "Sent from an abcdesign.app brief session."; + +// COMMIT_RE is a commit id as an issue or a pull request names the base: the +// forty hex digits of a SHA-1 commit. +const COMMIT_RE = /^[0-9a-f]{40}$/; + +// baseCommit is the base commit as the host seeded it, or "unknown" for +// anything that is not a commit id: it goes into a Markdown body. +function baseCommit() { + const sha = effectiveMeta().base_sha; + return typeof sha === "string" && COMMIT_RE.test(sha) ? sha : "unknown"; +} + +// mdName makes a guest's name inert in a Markdown body: it goes in a code +// span, after trailerText's cleaning (no line breaks, no angle brackets, no +// @). Escaping alone is not enough on GitHub, which autolinks a bare URL or +// domain (evil.example/x, www.example, a full web address) whatever is +// escaped around it; inside a code span nothing is a link, a mention, an +// image or emphasis. The fence is longer than any run of backticks in the +// name, and a name that starts or ends with a backtick is padded with a +// space each side, which the span drops again. +function mdName(s) { + const name = trailerText(s); + let longest = 0; + for (const m of name.matchAll(/`+/g)) longest = Math.max(longest, m[0].length); + const fence = "`".repeat(longest + 1); + const pad = name.startsWith("`") || name.endsWith("`") ? " " : ""; + return fence + pad + name + pad + fence; +} + +function issueTitle(p) { + return "Brief: " + relPath(p.path) + ", " + linesLabel(p); +} + +function issueBody(p) { + return [ + "A change to the brief, proposed in a live session.", + "", + "- File: `" + p.path + "`", + "- Base commit: " + baseCommit(), + "- At the base commit: " + linesLabel(p), + "", + "Before:", + "", + fenced(p.before), + "", + "After:", + "", + fenced(p.after), + "", + ...creditLines(p, mdName), + "", + ISSUE_MARKER, + ].join("\n"); +} + +// issueLink builds the passage's pre-filled issue link and says whether it +// fits: measured whole, as GitHub receives it, after encoding, in bytes. +function issueLink(p) { + const url = GITHUB_WEB + "/" + encodeURIComponent(route.owner) + "/" + encodeURIComponent(route.repo) + + "/issues/new?title=" + encodeURIComponent(issueTitle(p)) + "&body=" + encodeURIComponent(issueBody(p)); + return { url, fits: utf8Length(url) <= ISSUE_URL_LIMIT }; +} + +// --------------------------------------------------------------------------- +// The pull request route: a branch at the base commit, one commit made as the +// submitter, crediting everyone else, and the pull request. +// --------------------------------------------------------------------------- +// utf8Base64 encodes text as GitHub's API wants file contents: the UTF-8 +// bytes, in base64. +function utf8Base64(text) { + const bytes = new TextEncoder().encode(text); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(binary); +} + +function branchName() { + const d = new Date(); + const two = (n) => String(n).padStart(2, "0"); + let tail = ""; + for (const b of crypto.getRandomValues(new Uint8Array(6))) tail += (b % 36).toString(36); + return "abcdesign/" + d.getUTCFullYear() + two(d.getUTCMonth() + 1) + two(d.getUTCDate()) + "-" + tail; +} + +// trailerText makes a guest's display name safe for one trailer line, an +// issue body and a pull request body: no line breaks or angle brackets, and +// "@" written "(at)", so a name cannot mention anyone on GitHub. The page +// itself shows the name as the guest gave it. +function trailerText(s) { + const t = String(s || "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029<>]/g, " ").replace(/@/g, "(at)").replace(/\s+/g, " ").trim().slice(0, 60); + return t || "Guest"; +} + +function noreply(login, ids) { + return login + " <" + ids.get(login.toLowerCase()) + "+" + login + "@users.noreply.github.com>"; +} + +// composeTrailers credits everyone but the submitter, who is the commit's +// author: a co-author line per credited member, and for each approved guest +// change the guest's line with its approver's line directly beneath. +function composeTrailers(submitter, members, pairs, ids) { + const lines = []; + for (const login of members) { + if (login.toLowerCase() !== submitter.toLowerCase()) lines.push("Co-authored-by: " + noreply(login, ids)); + } + for (const p of pairs) lines.push("Suggested-by: " + p.guest + " (guest)", "Approved-by: " + noreply(p.approver, ids)); + return lines; +} + +// applyPassages puts some of a file's passages onto its base text. +function applyPassages(base, list) { + let text = base; + for (const p of [...list].sort((a, b) => b.baseStart - a.baseStart)) { + text = text.slice(0, p.baseStart) + p.after + text.slice(p.baseEnd); + } + return text; +} + +async function ghSend(method, path, body) { + let res; + try { + res = await fetch(GITHUB_API + path, { + method, + headers: { Accept: "application/vnd.github+json", "Content-Type": "application/json", Authorization: "Bearer " + githubToken }, + body: body === undefined ? undefined : JSON.stringify(body), + credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", + }); + } catch { + return { status: 0, json: null }; + } + const json = await res.json().catch(() => null); + return { status: res.status, json }; +} + +// GitHubTrouble is a failed step, already in words the person can act on. +class GitHubTrouble extends Error {} + +function trouble(step, status, errors) { + const types = Array.isArray(errors) ? errors.map((e) => (e && typeof e.type === "string" ? e.type : "")) : []; + if (status === 0) return new GitHubTrouble(WORDS.ghUnreachable); + if (status === 401) return new GitHubTrouble(WORDS.ghSignIn); + if (status === 403 || types.includes("FORBIDDEN") || types.includes("INSUFFICIENT_SCOPES")) return new GitHubTrouble(WORDS.ghScope); + if (status === 404 || types.includes("NOT_FOUND")) return new GitHubTrouble(WORDS.ghNotFound); + if (step === "commit" && (status === 422 || types.includes("STALE_DATA"))) return new GitHubTrouble(WORDS.ghStale); + if (step === "ref" && status === 422) return new GitHubTrouble(WORDS.ghRefTaken); + return new GitHubTrouble(WORDS.ghOther); +} + +function prBody(list, members, pairs, changed) { + const people = new Set(members.map((l) => "@" + l)); + for (const p of pairs) people.add(mdName(p.guest) + " (guest)"); + for (const r of session.roster) { + if (r.role === "member" && typeof r.login === "string" && NAME_RE.test(r.login)) people.add("@" + r.login); + else if (r.role !== "member" && typeof r.name === "string" && r.name) people.add(mdName(trailerText(r.name)) + " (guest)"); + } + const out = [ + "Edits to the brief made together in a live session, from base commit " + baseCommit() + ".", + "", + "Participants: " + [...people].join(", "), + "", + "Changed passages:", + ]; + for (const p of list) out.push("- `" + relPath(p.path) + "`, " + linesLabel(p) + ": " + creditLines(p, mdName).join("; ")); + if (pairs.length) { + out.push("", "Approved guest suggestions:"); + for (const p of pairs) out.push("- " + mdName(p.guest) + " (guest), approved by @" + p.approver); + } + out.push("", "Changed files:", ...changed.map((f) => "- `" + f + "`")); + return out.join("\n"); +} + +let sending = false; +// openPullRequest sends the session as one pull request: every passage, or +// (one too long for an issue) the single passage shown, read afresh so what +// is sent is what the panel shows. +async function openPullRequest(only) { + if (sending) return; + const analysis = analyse(); + const all = analysis.flatMap((f) => f.passages); + const list = only ? all.filter((p) => p.path === only.path && p.before === only.before && p.after === only.after) : all; + if (list.length === 0) { + showSendResult(only ? WORDS.passageMoved : WORDS.sendNothing, true); + renderSend(); + return; + } + if (!githubToken || session.role !== "member" || !session.login) { showSendResult(WORDS.prNeedsSignIn, true); return; } + // The base commit, the branch the pull request goes into and the + // repository, as the host seeded them; nobody else's write counts. + const settings = effectiveMeta(); + const baseSha = settings.base_sha; + const target = settings.branch; + if (!SHA_RE.test(baseSha || "") || typeof target !== "string" || !target || + typeof settings.repo !== "string" || settings.repo.toLowerCase() !== route.slug.toLowerCase()) { + showSendResult(WORDS.prNoBase, true); + return; + } + sending = true; + renderSend(); + showSendResult(WORDS.prWorking, false); + let branch = ""; + let refMade = false; + try { + // The files: each changed file with these passages put on its base text. + const additions = []; + const changed = []; + const baseFiles = baseFilePaths(baseSnapshot()); + for (const f of analysis) { + const mine = list.filter((p) => p.path === f.path); + if (mine.length === 0) continue; + const text = mine.length === f.passages.length ? f.current : applyPassages(f.base, mine); + if (text === f.base) continue; + if (!briefFile(f.path, baseFiles)) throw new GitHubTrouble(WORDS.pathRefused + " " + shownPath(f.path)); + additions.push({ path: f.path, contents: utf8Base64(text) }); + changed.push(f.path); + } + if (additions.length === 0) { showSendResult(WORDS.sendNothing, true); return; } + + // Who to credit, and their GitHub ids for the noreply addresses. + const credited = new Map(); + const pairs = new Map(); + for (const p of list) { + for (const l of p.authors) credited.set(l.toLowerCase(), l); + for (const a of p.approvals) { + if (!NAME_RE.test(a.approver)) continue; + credited.set(a.approver.toLowerCase(), a.approver); + const guest = trailerText(a.guest); + // One pair per approval, though it spans several passages. + pairs.set(a.key, { guest, approver: a.approver }); + } + } + const members = [...credited.values()].sort((a, b) => a.localeCompare(b)); + const ids = new Map(); + for (const login of members) { + if (login.toLowerCase() === session.login.toLowerCase() && ![...pairs.values()].some((p) => p.approver.toLowerCase() === login.toLowerCase())) continue; + const r = await ghSend("GET", "/users/" + encodeURIComponent(login)); + if (r.status !== 200 || !r.json || !Number.isSafeInteger(r.json.id)) throw trouble("users", r.status); + ids.set(login.toLowerCase(), r.json.id); + } + const trailers = composeTrailers(session.login, members, [...pairs.values()], ids); + + // 1. The branch, at the commit the session started from. + branch = branchName(); + const ref = await ghSend("POST", repoPath() + "/git/refs", { ref: "refs/heads/" + branch, sha: baseSha }); + if (ref.status !== 201) throw trouble("ref", ref.status); + refMade = true; + + // 2. One commit on it, made as the submitter. + const message = { headline: COMMIT_HEADLINE, body: "Changed files:\n" + changed.map((f) => "- " + f).join("\n") + (trailers.length ? "\n\n" + trailers.join("\n") : "") }; + const commit = await ghSend("POST", "/graphql", { + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }", + variables: { + input: { + branch: { repositoryNameWithOwner: route.slug, branchName: branch }, + message, + expectedHeadOid: baseSha, + fileChanges: { additions }, + }, + }, + }); + const made = commit.json && commit.json.data && commit.json.data.createCommitOnBranch && commit.json.data.createCommitOnBranch.commit; + if (commit.status !== 200 || !made || (commit.json.errors && commit.json.errors.length)) { + throw trouble("commit", commit.status === 200 && !made ? 422 : commit.status, commit.json && commit.json.errors); + } + + // 3. The pull request into the branch the session read. + const pr = await ghSend("POST", repoPath() + "/pulls", { + title: COMMIT_HEADLINE, head: branch, base: target, body: prBody(list, members, [...pairs.values()], changed), + }); + if (pr.status !== 201 || !pr.json || typeof pr.json.html_url !== "string") { + refMade = false; // the commit is worth keeping: the person can open the pull request by hand + throw new GitHubTrouble(WORDS.ghPRRefused + " " + branch + "."); + } + refMade = false; + showSendResult(WORDS.prOpened, false, pr.json.html_url); + } catch (err) { + if (refMade) { + // The branch holds nothing yet: take it away again. + await ghSend("DELETE", repoPath() + "/git/refs/heads/" + branch.split("/").map(encodeURIComponent).join("/")); + } + showSendResult(err instanceof GitHubTrouble ? err.message : WORDS.ghOther, true); + } finally { + sending = false; + renderSend(); + } +} + +// --------------------------------------------------------------------------- +// The "Send to GitHub" panel: the passages, their credit and the route's +// actions. It reads meta.mode, the approvals, the base snapshot and the +// roster's credits. +// --------------------------------------------------------------------------- +function submitSession() { + if (!ui.send.hidden) { closeSend(); return; } + noteOpener("send"); + ui.send.hidden = false; + showSendResult("", false); + renderSend(); + applyLayout(); + ui.sendHeading.focus(); +} + +function showSendResult(text, bad, href) { + clear(ui.sendResult); + ui.sendResult.className = bad ? "state bad" : "state"; + if (text) ui.sendResult.append(text); + if (href) { + let ok = false; + try { ok = new URL(href).origin === new URL(GITHUB_WEB).origin; } catch { ok = false; } + ui.sendResult.append(" ", ok ? el("a", { href, target: "_blank", rel: "noopener noreferrer", text: href }) : href); + } +} + +let sendTimer = 0; +doc.on("update", () => { + if (!ui.send || ui.send.hidden) return; + clearTimeout(sendTimer); + sendTimer = setTimeout(() => renderSend(), 600); +}); + +function closeSend() { + ui.send.hidden = true; + applyLayout(); + returnFocus("send"); +} + +function renderSend() { + if (!ui.send || ui.send.hidden) return; + if (session.role !== "member" || session.over) { closeSend(); return; } + const mode = effectiveMeta().mode === "issues" ? "issues" : "pr"; + const analysis = analyse(); + const all = analysis.flatMap((f) => f.passages); + ui.sendIntro.textContent = mode === "issues" ? WORDS.sendIntroIssues : WORDS.sendIntroPR; + clear(ui.sendList); + clear(ui.sendActions); + for (const path of analysis.refused) { + ui.sendList.append(el("li", { class: "state bad", text: WORDS.pathRefused + " " + shownPath(path) })); + } + if (all.length === 0) { + ui.sendList.append(el("li", { class: "state", text: WORDS.sendNothing })); + } + for (const p of all) { + const actions = el("div", { class: "row" }); + if (mode === "issues") { + const link = issueLink(p); + if (link.fits) { + actions.append(el("a", { class: "button", href: link.url, target: "_blank", rel: "noopener noreferrer", text: WORDS.sendAsIssue })); + } else { + actions.append(el("span", { class: "state", text: WORDS.tooLongForIssue }), + el("button", { type: "button", disabled: sending, text: WORDS.sendAsPR, onclick: () => openPullRequest(p) })); + } + } + ui.sendList.append(el("li", { class: "passage" }, + icon(fileIcon(p.path)), + el("div", { class: "passage-body" }, + el("div", { class: "from", text: relPath(p.path) + " · " + linesLabel(p) }), + el("div", { class: "credit", text: creditLines(p).join("\n") }), + el("del", { text: p.before }), + el("ins", { text: p.after }), + actions.childElementCount ? actions : null))); + } + if (mode === "pr") { + ui.sendActions.append(el("button", { + type: "button", class: "primary", disabled: sending || all.length === 0 || !githubToken, + text: WORDS.openPR, onclick: () => openPullRequest(null), + })); + if (!githubToken) ui.sendActions.append(el("span", { class: "state", text: WORDS.prNeedsSignIn })); + } + ui.sendActions.append( + el("button", { type: "button", text: WORDS.sendRefresh, onclick: () => renderSend() }), + el("button", { type: "button", class: "back", text: WORDS.backToSession, onclick: () => closeSend() })); +} + + +// --------------------------------------------------------------------------- +// The editor. +// --------------------------------------------------------------------------- +let view = null; +let currentPath = ""; +function openFile(path) { + if (view) { view.destroy(); view = null; } + clear(ui.editor); + awareness.setLocalStateField("cursor", null); + setAwarenessUser(); + const ytext = path ? files.get(path) : null; + if (!(ytext instanceof Y.Text)) { + currentPath = ""; + ui.filetitle.textContent = WORDS.noFile; + renderFiles(); + renderGuestBar(); + return; + } + currentPath = path; + ui.filetitle.textContent = prettyName(path) + " · " + relPath(path); + const extensions = [ + keymap.of([...yUndoManagerKeymap]), + basicSetup, + markdown(), + EditorView.lineWrapping, + yCollab(ytext, awareness), + ]; + if (session.role !== "member") { + // A guest reads and selects; the text changes only through a member. + extensions.push(EditorState.readOnly.of(true), EditorView.contentAttributes.of({ inputmode: "none" })); + extensions.push(EditorView.updateListener.of((u) => { if (u.selectionSet) renderGuestBar(); })); + } + view = new EditorView({ state: EditorState.create({ doc: ytext.toString(), extensions }), parent: ui.editor }); + renderFiles(); + renderGuestBar(); +} +// firstFile is the file a session opens on: the brief's press release, the +// file whose name holds "press-release", or else the first file. +function firstFile() { + const paths = [...files.keys()].sort(); + return paths.find((p) => /press-release/i.test(p.slice(p.lastIndexOf("/") + 1))) || paths[0] || ""; +} +files.observe(() => { + renderFiles(); + if (!currentPath || !files.has(currentPath)) { + const first = firstFile(); + if (first && session.synced) openFile(first); + } +}); + +// --------------------------------------------------------------------------- +// The two designs. Guided (the default) and Shared are one page, one set of +// elements and one set of logic: the design is an attribute the stylesheet +// reads, and a few parts (the documents, the people, the suggestions, the +// invite, the editor) move into the design's own places. Ctrl+Shift+[ and +// Ctrl+Shift+] (Ctrl+{ and Ctrl+}) switch on every platform, the Mac +// included, leaving Ctrl+[ and Ctrl+] to the editor's indent and outdent; +// Alt+Shift+D and Alt+Shift+S show and hide the two sidebars; Escape closes +// the overlay on top. +// --------------------------------------------------------------------------- +let design = DESIGNS[0]; +// The sidebars. A wide window keeps whether each shows (left, right); at +// the slide-over widths the documents follow the wide window but the right +// panel, which floats over the page there, keeps its own (slide), so closing +// it there never hides it on a wide window. A phone keeps its own: the +// guided documents card (pdocs) and open sheet (sheet), and the shared +// full-screen documents (pleft) and suggestions sheet (pright). Kept here, +// outside any screen, so it lasts through every change of screen, width and +// design. The invite card is the shared design's alone. +const layout = { left: true, right: true, slide: true, pdocs: true, sheet: "", pleft: false, pright: false, invite: false }; + +// storedDesign is the design this viewer chose last, or the default. +function storedDesign() { + const d = recall(KEY_DESIGN); + return DESIGNS.includes(d) ? d : DESIGNS[0]; +} +function switchDesign(step) { + const i = DESIGNS.indexOf(design); + design = DESIGNS[(i + step + DESIGNS.length) % DESIGNS.length]; + remember(KEY_DESIGN, design); + applyLayout(); + toast(design === "shared" ? WORDS.designShared : WORDS.designGuided); +} + +// shortcut names what a key press asks for: the design switch on +// Ctrl+Shift+[ and Ctrl+Shift+] (never Cmd), a sidebar on Alt+Shift+D or +// Alt+Shift+S, or Escape. Each is read from the key's place, so a layout's +// or a Mac's Option characters do not matter, and AltGr typing a character +// (which some systems report as Ctrl+Alt) is never one. +function shortcut(e) { + if (e.key === "Escape") return "escape"; + if (e.metaKey) return ""; + if (typeof e.getModifierState === "function" && e.getModifierState("AltGraph")) return ""; + if (e.ctrlKey && e.shiftKey && !e.altKey) { + if (e.code === "BracketLeft") return "design-prev"; + if (e.code === "BracketRight") return "design-next"; + return ""; + } + if (e.altKey && e.shiftKey && !e.ctrlKey) { + if (e.code === "KeyD") return "left"; + if (e.code === "KeyS") return "right"; + } + return ""; +} +function onKey(e) { + // A key that ends an input method's composition is the composition's. + if (e.isComposing || e.keyCode === 229) return; + const action = shortcut(e); + if (!action) return; + if (action === "escape") { + // In the editor, Escape is the editor's: it closes its search panel + // and its own selections. + if (view && e.target && view.dom.contains(e.target)) return; + if (closeTop()) { e.preventDefault(); e.stopPropagation(); } + return; + } + if ((action === "left" || action === "right") && !inWorkspace()) return; + e.preventDefault(); + e.stopPropagation(); + if (action === "design-prev") switchDesign(-1); + else if (action === "design-next") switchDesign(1); + else toggleSide(action); +} + +function viewWidth() { + return document.documentElement.clientWidth || window.innerWidth || 0; +} +function inWorkspace() { + return !!ui.work && !ui.work.hidden; +} +// tier is which of the sidebars' states this width uses: "phone", "slide" +// (where the right panel slides over the page) or "wide". +function tier() { + const w = viewWidth(); + return w <= PHONE_MAX ? "phone" : w <= SLIDE_MAX[design] ? "slide" : "wide"; +} + +// closeTop closes the overlay on top that can be seen, and says whether +// there was one: the relay confirmation (which is then refused), the Send +// panel, the invite card, the guest's suggestion popover, a phone's sheet +// or sidebar, then a slide-over. State another design or width keeps is +// not on top of anything. +function closeTop() { + if (ui.trust && !ui.trust.hidden) { trustAnswered(false); return true; } + if (!inWorkspace()) return false; + if (!ui.send.hidden) { closeSend(); return true; } + if (design === "shared" && layout.invite) { closeInvite(); return true; } + if (design === "shared" && !ui.suggestBox.hidden && !ui.suggestForm.hidden) { ui.suggestForm.hidden = true; applyLayout(); return true; } + const t = tier(); + if (t === "phone") { + if (design === "guided" && layout.sheet) { openSheet(""); return true; } + if (design === "shared" && (layout.pleft || layout.pright)) { layout.pleft = false; layout.pright = false; applyLayout(); return true; } + return false; + } + if (t === "slide" && layout.slide) { layout.slide = false; applyLayout(); return true; } + return false; +} + +// defaultSheet is the right sidebar's first panel on a guided phone. +function defaultSheet() { + return session.role === "member" ? "sugs" : "suggest"; +} +function toggleSide(side) { + const t = tier(); + if (t === "phone" && design === "shared") { + if (side === "left") { layout.pleft = !layout.pleft; if (layout.pleft) layout.pright = false; } + else { layout.pright = !layout.pright; if (layout.pright) layout.pleft = false; } + } else if (t === "phone") { + if (side === "left") layout.pdocs = !layout.pdocs; + else layout.sheet = layout.sheet ? "" : defaultSheet(); + } else if (t === "slide" && side === "right") { + layout.slide = !layout.slide; + } else { + layout[side] = !layout[side]; + } + applyLayout(); +} +function openSheet(name) { + layout.sheet = name; + applyLayout(); +} + +// sideShown says whether a sidebar shows, at this width and in this design. +function sideShown(side) { + const t = tier(); + if (t === "phone") { + if (design === "shared") return side === "left" ? layout.pleft : layout.pright; + return side === "left" ? layout.pdocs : !!layout.sheet; + } + if (side === "right" && t === "slide") return layout.slide; + return layout[side]; +} + +// The invite card (Shared): it takes focus when it opens and gives it back +// to what opened it. +function toggleInvite() { + if (layout.invite) { closeInvite(); return; } + noteOpener("invite"); + layout.invite = true; + applyLayout(); + focusOn(ui.copyLink && ui.sShare.contains(ui.copyLink) ? ui.copyLink : ui.shareClose); +} +function closeInvite() { + layout.invite = false; + applyLayout(); + returnFocus("invite"); +} + +// Focus. A dialog that opens takes focus, and gives it back to the control +// that opened it when it closes; a focused control a change of layout hides +// hands focus to the one that does its job now, so the keyboard is never +// left on something nobody can see. +const openers = { send: null, invite: null, trust: null }; +function noteOpener(name) { + const a = document.activeElement; + openers[name] = a && a !== document.body ? a : null; +} +function returnFocus(name) { + const o = openers[name]; + openers[name] = null; + if (!o) return; + focusOn(hiddenNow(o) ? focusFallback(o) : o); +} +function focusOn(node) { + if (!node) return; + if (view && view.dom.contains(node)) view.focus(); + else node.focus(); +} +// hiddenNow says whether a person can see an element now: not hidden, not +// the other design's, not in a closed sidebar or sheet. +function hiddenNow(node) { + if (!node || !node.isConnected) return true; + const other = design === "shared" ? "g-only" : "s-only"; + for (let n = node; n && n.nodeType === 1; n = n.parentNode) { + if (n.hidden || n.classList.contains(other)) return true; + } + if ([ui.gDocsCard, ui.sSidebar].some((c) => c.contains(node)) && !sideShown("left")) return true; + if ([ui.gSide, ui.sAside].some((c) => c.contains(node)) && !sideShown("right")) return true; + if (tier() === "phone" && design === "guided") { + const box = ui.sheets.find((b) => b.contains(node)); + if (box && !box.classList.contains("is-open")) return true; + } + if (typeof node.checkVisibility === "function") return !node.checkVisibility({ visibilityProperty: true }); + return false; +} +// focusFallback is where focus goes when a control it was on is hidden: the +// same control in the other design's place, the toggle of a sidebar that +// closed over it, the editor, or the screen's heading. +function focusFallback(had) { + const pairs = [[ui.gLeft, ui.sLeft], [ui.gRight, ui.sRight], [ui.submitG, ui.submit], [ui.suggestButton, ui.suggestTop]]; + const next = []; + for (const pair of pairs) if (pair.includes(had)) next.push(...pair); + if ([ui.gDocsCard, ui.sSidebar].some((c) => c.contains(had))) next.push(ui.gLeft, ui.sLeft); + if ([ui.gSide, ui.sAside].some((c) => c.contains(had))) next.push(ui.gRight, ui.sRight); + if (view) next.push(view.contentDOM); + next.push(ui.bannerTitle, ui.heroTitle); + return next.find((n) => n && n !== had && !hiddenNow(n)) || null; +} +// settleFocus puts focus back where it was before a change of layout, if +// that is still seen (a part that moved between the designs, the editor +// among them, loses focus as it moves), or on its fallback if not. +function settleFocus(had) { + if (!had || had === document.body || !ui.app.contains(had)) return; + const target = hiddenNow(had) ? focusFallback(had) : had; + if (target && document.activeElement !== target) focusOn(target); +} + +// applyLayout writes the design and the sidebars' state where the +// stylesheet reads it, marks the toggles on or off, moves the shared parts +// into the design's places, and keeps focus on something seen. +function applyLayout() { + document.documentElement.dataset.design = design; + if (!ui.app) return; + const had = document.activeElement; + if (design !== "shared") layout.invite = false; + const L = sideShown("left"); + const R = sideShown("right"); + ui.app.dataset.left = L ? "shown" : "hidden"; + ui.app.dataset.right = R ? "shown" : "hidden"; + ui.app.dataset.sheet = tier() === "phone" && design === "guided" ? layout.sheet : ""; + ui.app.dataset.overlay = !ui.send.hidden ? "send" : layout.invite ? "invite" : ""; + // Each toggle has one name, and says whether it is on with aria-pressed. + const rightName = session.role === "guest" ? WORDS.suggestFollow : WORDS.reviewPeople; + const sharedRight = session.role === "guest" ? WORDS.mySuggestionsHeading : WORDS.sugShort; + for (const b of [ui.gLeft, ui.sLeft]) b.setAttribute("aria-pressed", String(L)); + for (const b of [ui.gRight, ui.sRight]) b.setAttribute("aria-pressed", String(R)); + ui.gRight.setAttribute("aria-label", rightName); + ui.sRight.setAttribute("aria-label", sharedRight); + ui.gRight.title = rightName + " (Alt+Shift+S)"; + ui.sRight.dataset.tip = sharedRight; + ui.gRightLabel.textContent = rightName; + ui.sRightLabel.textContent = sharedRight; + ui.gSideTitle.textContent = rightName; + ui.inviteToggle.setAttribute("aria-expanded", String(layout.invite)); + for (const box of ui.sheets) { + box.classList.toggle("is-open", !!layout.sheet && box.dataset.panel === layout.sheet); + } + for (const tab of ui.tabs) tab.setAttribute("aria-pressed", String((tab.dataset.sheet || "") === layout.sheet)); + placeParts(); + renderTop(); + fitTiles(); + // The invite card opens under its button. + if (layout.invite && viewWidth() > PHONE_MAX) { + const r = ui.inviteToggle.getBoundingClientRect(); + ui.sShare.style.setProperty("--share-r", Math.max(12, Math.round(viewWidth() - (r.left + r.width / 2) - 90)) + "px"); + } + settleFocus(had); +} + +// placeParts moves the parts both designs show into this design's places. +let placedFor = ""; +function placeParts() { + if (placedFor === design) return; + placedFor = design; + if (design === "shared") { + ui.sDocs.append(ui.files); + ui.sPeople.append(ui.rosterBox); + ui.sInsp.append(ui.notesBox, ui.sugBox, ui.mineBox); + ui.sShare.append(ui.inviteBox); + ui.sPop.append(ui.suggestBox); + ui.sDoc.append(ui.page); + } else { + ui.gDocs.append(ui.files); + ui.gSide.append(ui.notesBox, ui.suggestBox, ui.sugBox, ui.mineBox, ui.rosterBox, ui.inviteBox); + ui.gPaper.append(ui.page); + } + if (view) view.requestMeasure(); +} + +// fitTiles folds the people in the shared team bar into one summary when +// they do not fit whole: a tile is never cut. +function fitTiles() { + if (!ui.hud) return; + ui.hud.classList.remove("crowded"); + if (design === "shared" && ui.tiles.scrollWidth > ui.tiles.clientWidth + 1) ui.hud.classList.add("crowded"); +} + +// --------------------------------------------------------------------------- +// Rendering. +// --------------------------------------------------------------------------- +const ui = {}; +let statusText = ""; +let statusBad = false; +function setStatus(text, bad) { + statusText = text; + statusBad = bad; + paintStatus(); +} +// paintStatus writes the status into each place a design shows it. +function paintStatus() { + if (!ui.statuses) return; + for (const s of ui.statuses) { + s.textContent = statusText; + s.classList.toggle("bad", statusBad); + } + const n = session.roster.length; + const live = !statusBad && (statusText === WORDS.connected || !statusText); + ui.liveDot.textContent = live ? say(WORDS.liveHere, { n }) : statusText; + ui.liveDot.classList.toggle("bad", !live); + ui.hudStatus.textContent = live ? WORDS.connected : statusText; + ui.hudLive.classList.toggle("bad", !live); + ui.hudCount.textContent = say(WORDS.hereCount, { n }); +} + +// say puts values into a word's {placeholders}; the result is shown with +// textContent like any other text. +function say(word, values) { + return word.replace(/\{([a-z]+)\}/g, (m, k) => (Object.prototype.hasOwnProperty.call(values, k) ? String(values[k]) : m)); +} + +// The page's icons, as path data: [d] is stroked, [d, opacity] filled. +const ICONS = Object.freeze({ + doc: ["M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z", "M14 3v5h5M8.5 12.5h7M8.5 16h7"], + faq: ["M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v8a2.5 2.5 0 0 1-2.5 2.5H11l-4.5 3.5V17A2.5 2.5 0 0 1 4 14.5z", "M10 8.7a2 2 0 1 1 2.6 1.9c-.4.2-.6.5-.6.9v.4M12 14.2h.01"], + person: ["M12 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z", "M5 20c.7-3.6 3.5-6 7-6s6.3 2.4 7 6"], + people: ["M9 11.7a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z", "M3.5 19c.5-3.2 2.7-5 5.5-5s5 1.8 5.5 5", "M16.8 11.8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z", "M15.6 14.2c2.6-.3 4.6 1.3 5 4.3"], + noentry: ["M12 20.5a8.5 8.5 0 1 0 0-17 8.5 8.5 0 0 0 0 17z", "M6 6l12 12"], + panelLeft: [["M5 5.5h4.5v13H5a1.5 1.5 0 0 1-1.5-1.5V7A1.5 1.5 0 0 1 5 5.5z", ".28"], "M6 5.5h12a2.5 2.5 0 0 1 2.5 2.5v8a2.5 2.5 0 0 1-2.5 2.5H6A2.5 2.5 0 0 1 3.5 16V8A2.5 2.5 0 0 1 6 5.5z", "M9.5 5.5v13"], + panelRight: [["M14.5 5.5H19A1.5 1.5 0 0 1 20.5 7v10a1.5 1.5 0 0 1-1.5 1.5h-4.5z", ".28"], "M6 5.5h12a2.5 2.5 0 0 1 2.5 2.5v8a2.5 2.5 0 0 1-2.5 2.5H6A2.5 2.5 0 0 1 3.5 16V8A2.5 2.5 0 0 1 6 5.5z", "M14.5 5.5v13"], + invite: ["M10 11.9a3.4 3.4 0 1 0 0-6.8 3.4 3.4 0 0 0 0 6.8z", "M3.5 19.5c.5-3.4 3-5.4 6.5-5.4 1.4 0 2.6.3 3.6.9M18 13.5v6M15 16.5h6"], + send: ["M20.5 3.5L10.2 13.8", "M20.5 3.5l-6.3 17-4-6.7-6.7-4z"], + check: ["M5 12.5l4.5 4.5L19 7.5"], + x: ["M6.5 6.5l11 11M17.5 6.5l-11 11"], + copy: ["M11 8.5h6a2.5 2.5 0 0 1 2.5 2.5v6a2.5 2.5 0 0 1-2.5 2.5h-6A2.5 2.5 0 0 1 8.5 17v-6A2.5 2.5 0 0 1 11 8.5z", "M15.5 8.5V6.5a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h2"], + pencil: ["M4.5 19.5l1-4.2L15.8 5a2 2 0 0 1 2.9 0l.3.3a2 2 0 0 1 0 2.9L8.7 18.5z", "M13.8 7l3.2 3.2"], + eye: ["M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"], + bubble: ["M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v8a2.5 2.5 0 0 1-2.5 2.5H11l-4.5 3.5V17A2.5 2.5 0 0 1 4 14.5z", "M8 9h8M8 12.5h5"], + right: ["M9 5l7 7-7 7"], + out: ["M9 5h10v10M19 5L6 18"], + brief: ["M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z", "M14 3v5h5", "M8.5 13.5l2 2 4-4.5"], + laptop: ["M6 5h12a1.5 1.5 0 0 1 1.5 1.5v9h-15v-9A1.5 1.5 0 0 1 6 5z", "M2.5 19h19"], + hand: ["M8 12V6a1.5 1.5 0 0 1 3 0v5M11 11V4.5a1.5 1.5 0 0 1 3 0V11M14 11V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7.5-6.5 7.5-2.5 0-4-1.3-5.5-3.5L3.6 14a1.5 1.5 0 0 1 2.4-1.8L8 14.5"], + clock: ["M12 20.5a8.5 8.5 0 1 0 0-17 8.5 8.5 0 0 0 0 17z", "M12 7.5V12l3 2"], + keypad: ["M5 6h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z", "M7 10h.01M11 10h.01M15 10h.01M7 14h10"], + shield: ["M12 3l8 3v6c0 4.5-3.4 8.3-8 9-4.6-.7-8-4.5-8-9V6z", "M8.5 12l2.5 2.5 4.5-5"], + next: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 8l4 4-4 4M8 12h8"], + refresh: ["M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"], + github: [["M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.52 2.34 1.08 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.6 9.6 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85v2.75c0 .27.18.58.69.48A10 10 0 0 0 12 2z", "1"]], +}); +function icon(name, cls) { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("class", cls ? "i " + cls : "i"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + for (const part of ICONS[name] || []) { + const path = document.createElementNS(SVG_NS, "path"); + if (Array.isArray(part)) { + path.setAttribute("d", part[0]); + path.setAttribute("class", "fill"); + path.setAttribute("opacity", part[1]); + } else { + path.setAttribute("d", part); + } + svg.append(path); + } + return svg; +} +// fileIcon picks a document's icon from its name. +function fileIcon(path) { + const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + if (/faq|question/.test(name)) return "faq"; + if (/who|audience|user|customer|people/.test(name)) return "person"; + if (/scope|non-goal|not-/.test(name)) return "noentry"; + return "doc"; +} +// prettyName is a document's name as a reader says it: "01-press-release.md" +// is "Press release". +function prettyName(path) { + const name = path.slice(path.lastIndexOf("/") + 1); + const words = name.replace(/\.md$/i, "").replace(/^[0-9]+[-_. ]*/, "").replace(/[-_]+/g, " ").trim(); + if (/^faq$/i.test(words)) return "FAQ"; + return words ? words.charAt(0).toUpperCase() + words.slice(1) : name; +} +// avatar is a person's initial on their colour. The colour comes from the +// page's own list, never from another participant. +function avatar(name, id, cls) { + const a = el("span", { class: cls ? "av " + cls : "av", "aria-hidden": "true", text: ([...String(name || "").trim()][0] || "?").toUpperCase() }); + const n = Number(String(id || "").replace(/^p/, "")) || 0; + a.style.setProperty("--pc", COLOURS[n % COLOURS.length]); + return a; +} +// closeButton is every overlay's Close: an X, top right, named "Close". +function closeButton(onclick) { + return el("button", { type: "button", class: "close", "aria-label": WORDS.close, "data-tip": WORDS.close, "data-key": "Esc", onclick }, icon("x")); +} +// areaHead is a guided card's numbered title, with the phone sheet's Done. +function areaHead(text, extra) { + return el("div", { class: "card-head g-only" }, + el("h2", { class: "area-title" }, el("span", { class: "num", "aria-hidden": "true" }), el("span", { text }), extra || null), + el("button", { type: "button", class: "btn small sheet-close", text: WORDS.done, onclick: () => openSheet("") })); +} +// nextSteps is the guided design's "What happens next" as numbered steps. +function nextSteps(steps) { + return el("ol", { class: "steps" }, ...steps.map(([title, hint, state]) => + el("li", { class: state || "" }, el("div", {}, el("strong", { text: title }), hint ? el("span", { text: hint }) : null)))); +} + +function build() { + const app = document.getElementById("app"); + clear(app); + ui.toast = el("div", { class: "toast", role: "status", "aria-live": "polite", hidden: true }); + if (!route) { + app.append(el("p", { class: "notice", text: WORDS.wrongAddress }), ui.toast); + return false; + } + ui.app = app; + app.classList.add("app"); + design = storedDesign(); + document.title = route.slug + " · " + WORDS.title; + const repo = route.repo; + + // Where the status shows: the guided app bar, the shared team bar's Live + // and, before a session, under the shared page's title. + ui.status = el("span", { class: "status g-only" }); + ui.heroStatus = el("p", { class: "status s-only", role: "status" }); + ui.statuses = [ui.status, ui.heroStatus]; + ui.liveDot = el("span", { class: "live-dot", role: "status" }); + ui.hudStatus = el("b"); + ui.hudCount = el("small"); + ui.hudLive = el("div", { class: "hud-live" }, el("span", { class: "dot", "aria-hidden": "true" }), el("span", {}, ui.hudStatus, ui.hudCount)); + + // A guest's sign-in, in the guided app bar (an icon on a phone, so the + // project's name keeps its room) or the shared sidebar's foot. + ui.signInTop = el("button", { type: "button", class: "btn small tb-signin", onclick: () => startSignIn() }, + icon("github"), el("span", { class: "tb-label", text: WORDS.signIn })); + ui.submit = el("button", { type: "button", class: "btn primary tb-send tip-r", "aria-label": WORDS.submit, "data-tip": WORDS.sendTip, "data-key": "", onclick: () => submitSession() }, + icon("send"), el("span", { class: "lbl", "aria-hidden": "true", text: WORDS.submit })); + ui.suggestTop = el("button", { type: "button", class: "btn primary tb-send tip-r", "aria-label": WORDS.suggest, "data-tip": WORDS.suggestTip, "data-key": "", onclick: () => openSuggestForm() }, + icon("pencil"), el("span", { class: "lbl", "aria-hidden": "true", text: WORDS.suggest })); + + // The sidebar toggles: in the guided app bar, and at the two ends of the + // shared toolbar. Each keeps one name, with its shortcut in its tooltip, + // and says with aria-pressed whether its sidebar shows. + ui.gLeft = el("button", { type: "button", class: "btn small tb-toggle live-only", "aria-label": WORDS.documents, title: WORDS.documents + " (Alt+Shift+D)", "aria-keyshortcuts": "Alt+Shift+D", onclick: () => toggleSide("left") }, + icon("panelLeft"), el("span", { class: "tb-label", text: WORDS.documents })); + ui.gRightLabel = el("span", { class: "tb-label" }); + ui.gRight = el("button", { type: "button", class: "btn small tb-toggle live-only", "aria-keyshortcuts": "Alt+Shift+S", onclick: () => toggleSide("right") }, + icon("panelRight"), ui.gRightLabel); + ui.sLeft = el("button", { type: "button", class: "tbtn tg-left", "aria-label": WORDS.documents, "data-tip": WORDS.documents, "aria-keyshortcuts": "Alt+Shift+D", "data-key": "Alt+Shift+D", onclick: () => toggleSide("left") }, + icon("panelLeft"), el("span", { class: "lbl", "aria-hidden": "true", text: WORDS.documents })); + ui.sRightLabel = el("span", { class: "lbl", "aria-hidden": "true" }); + ui.rightCount = el("span", { class: "count", "aria-hidden": "true" }); + ui.sRight = el("button", { type: "button", class: "tbtn tg-right", "aria-keyshortcuts": "Alt+Shift+S", "data-key": "Alt+Shift+S", onclick: () => toggleSide("right") }, + icon("panelRight"), ui.sRightLabel, ui.rightCount); + + // The guided app bar. + ui.gMe = el("div", { class: "me" }); + const bar = el("header", { class: "top g-only" }, + ui.gLeft, el("span", { class: "tb-sep live-only" }), + el("div", { class: "logo" }, icon("brief")), + el("div", { class: "names" }, el("div", { class: "project", text: repo }), el("div", { class: "sub", text: say(WORDS.brandSub, { owner: route.owner }) })), + el("span", { class: "spacer" }), ui.status, ui.gMe, + el("span", { class: "tb-sep live-only" }), ui.gRight); + + // The start screen. It asks for nothing technical: no relay address and no + // token (adr-2610161400542916). It shows what the page can do here, in + // plain words, or why it cannot. + ui.heroTitleGuided = el("span", { class: "g-only" }); + ui.heroTitleShared = el("span", { class: "s-only" }); + ui.heroTitle = el("h1", { tabindex: "-1" }, ui.heroTitleGuided, ui.heroTitleShared); + ui.heroLede = el("p", { class: "lede g-only" }); + ui.heroLedeShared = el("p", { class: "lede s-only" }); + ui.hero = el("header", { class: "card hero" }, + el("div", { class: "appicon s-only" }, icon("brief")), + el("div", { class: "hero-names" }, el("p", { class: "eyebrow s-only", text: route.owner + " / " + repo }), ui.heroTitle), + ui.heroLede, ui.heroLedeShared, ui.heroStatus); + ui.notice = el("p"); + ui.retry = el("button", { type: "button", class: "btn primary", hidden: true, text: WORDS.tryAgain, onclick: () => recheckRelayList() }); + ui.noticeCard = el("section", { class: "card notice-card", hidden: true, role: "status" }, + el("div", { class: "illus s-only" }, icon("hand")), ui.notice, el("div", { class: "row" }, ui.retry)); + ui.askMessage = el("div", { class: "quote-box" }); + ui.askCard = el("section", { class: "card ask-card", hidden: true }, + el("h2", { text: WORDS.askHeading }), el("p", { class: "hint", text: WORDS.askHint }), ui.askMessage, + el("div", { class: "row" }, el("button", { type: "button", class: "btn primary", text: WORDS.askCopy, onclick: () => copyText(ui.askMessage.textContent, WORDS.askCopied) }))); + ui.code = el("input", { id: "code", class: "code-input", autocomplete: "off", spellcheck: "false", maxlength: "12", autocapitalize: "characters", placeholder: "K7M2QX" }); + ui.name = el("input", { id: "name", autocomplete: "nickname", maxlength: "40" }); + ui.mode = el("select", { id: "mode" }, + el("option", { value: "pr", text: WORDS.modePR }), el("option", { value: "issues", text: WORDS.modeIssues })); + ui.signedIn = el("p", { class: "state" }); + ui.signInNote = el("p", { class: "state" }); + ui.signInStart = el("button", { type: "button", class: "btn primary block", onclick: () => startSignIn() }, icon("github"), WORDS.signIn); + ui.hostRow = el("div", { class: "host-row", hidden: true }, + el("label", { for: "mode", text: WORDS.modeLabel }), ui.mode, + el("div", { class: "row" }, el("button", { type: "button", class: "btn primary block", text: WORDS.host, onclick: () => hostClicked() }))); + ui.startCard = el("section", { class: "card choice", hidden: true }, + el("div", { class: "card-ic", "aria-hidden": "true" }, icon("people")), + el("h2", { text: say(WORDS.teamHeading, { repo }) }), el("p", { text: WORDS.startHint }), + el("p", { class: "owner" }, ownerNotice()), + el("div", { class: "choice-act" }, ui.signInStart, ui.hostRow)); + // Joining: with a code from the invite link, members sign in and everyone + // else gives a name; without one, a smaller form takes a code given aloud. + ui.signInJoin = el("button", { type: "button", class: "btn primary block", onclick: () => startSignIn() }, icon("github"), WORDS.signIn); + ui.joinMembers = el("section", { class: "card choice", hidden: true }, + el("div", { class: "card-ic", "aria-hidden": "true" }, icon("pencil")), + el("h2", { text: say(WORDS.teamHeading, { repo }) }), + el("p", { text: WORDS.joinMembers }), el("p", { class: "owner" }, ownerNotice()), + el("div", { class: "choice-act" }, ui.signInJoin)); + ui.joinTitle = el("h2"); + ui.joinGuests = el("p", { text: WORDS.joinGuests }); + ui.codeHint = el("p", { text: WORDS.codeHint }); + ui.codeRow = el("div", { class: "field-group" }, el("label", { for: "code", text: WORDS.codeLabel }), ui.code); + ui.joinButton = el("button", { type: "submit", class: "btn primary block" }); + ui.joinCard = el("form", { class: "card choice", hidden: true, onsubmit: (e) => { e.preventDefault(); joinClicked(); } }, + el("div", { class: "card-ic", "aria-hidden": "true" }, icon("bubble")), + ui.joinTitle, ui.joinGuests, ui.codeHint, + el("div", { class: "choice-form" }, ui.codeRow, + el("div", { class: "field-group" }, el("label", { for: "name", text: WORDS.nameLabel }), ui.name, el("p", { class: "hint", text: WORDS.nameHint }))), + el("div", { class: "choice-act" }, ui.joinButton)); + ui.nextStart = el("div", { class: "next g-only", role: "note" }); + ui.start = el("div", { class: "start" }, + el("div", { class: "narrow" }, ui.hero, ui.noticeCard, ui.askCard, + el("div", { class: "signed" }, ui.signedIn, ui.signInNote), + el("div", { class: "choices" }, ui.startCard, ui.joinMembers, ui.joinCard), + ui.nextStart)); + + // GitHub's device flow: the code to confirm on github.com, which the page + // opens in a new tab. + ui.deviceCode = el("div", { class: "code bigcode", "aria-live": "polite" }); + ui.deviceOpen = el("a", { class: "button primary", href: GITHUB_DEVICE, target: "_blank", rel: "noopener noreferrer", text: WORDS.deviceOpen }, icon("out")); + ui.device = el("section", { class: "card device", hidden: true, role: "dialog", "aria-labelledby": "device-heading" }, + el("div", { class: "device-head" }, + el("div", { class: "illus s-only" }, icon("laptop")), + el("div", {}, el("p", { class: "eyebrow s-only", text: route.owner + " / " + repo }), el("h2", { id: "device-heading", text: WORDS.deviceHeading })), + el("p", { class: "lead", text: WORDS.deviceIntro })), + el("div", { class: "numbered" }, + el("div", { class: "n" }, el("b", { "aria-hidden": "true", text: "1" }), el("div", {}, + el("h3", { text: WORDS.deviceNote }), + el("div", { class: "devcode" }, ui.deviceCode, + el("button", { type: "button", class: "btn small", onclick: () => copyText(ui.deviceCode.textContent, WORDS.deviceCopied) }, icon("copy"), WORDS.deviceCopy)))), + el("div", { class: "n" }, el("b", { "aria-hidden": "true", text: "2" }), el("div", {}, + el("h3", { text: WORDS.deviceOpenTitle }), el("p", { class: "hint", text: WORDS.deviceOpenHint }), + el("div", { class: "row" }, ui.deviceOpen))), + el("div", { class: "n" }, el("b", { "aria-hidden": "true", text: "3" }), el("div", {}, + el("h3", { text: WORDS.deviceBack }), + el("div", { class: "waiting" }, el("span", { class: "spinner", "aria-hidden": "true" }), + el("div", {}, el("strong", { class: "state", text: WORDS.deviceWaiting }), el("span", { class: "hint", text: WORDS.deviceBackHint })))))), + el("div", { class: "row device-cancel" }, el("button", { type: "button", class: "btn", text: WORDS.cancel, onclick: () => stopDeviceSignIn(true) })), + el("div", { class: "next g-only", role: "note" }, + el("h2", {}, icon("next"), WORDS.whatNext), + nextSteps([[WORDS.nextChose, WORDS.nextChoseHint, "done"], [WORDS.nextConfirm, WORDS.nextHere, "here"], [WORDS.nextOpens, WORDS.nextOpensHint]]))); + + // The workspace's parts, which each design places for itself. + ui.files = el("div", { class: "docs" }); + ui.filetitle = el("div", { class: "filetitle" }); + ui.editor = el("div", { class: "editor" }); + ui.page = el("div", { class: "page" }, ui.filetitle, ui.editor); + ui.notesList = el("ul"); + ui.notesBox = el("section", { class: "notes", role: "alert", hidden: true }, el("h2", { text: WORDS.notesHeading }), ui.notesList); + ui.people = el("ul", { class: "people" }); + ui.rosterBox = el("section", { class: "box area", "data-panel": "people", "aria-label": WORDS.whoHere }, areaHead(WORDS.whoHere), ui.people); + ui.sugCount = el("span", { class: "count" }); + ui.sugList = el("div", { class: "sugs" }); + ui.sugBox = el("section", { class: "box area", "data-panel": "sugs", "aria-label": WORDS.suggestionsHeading, hidden: true }, + areaHead(WORDS.sugReview, ui.sugCount), el("p", { class: "area-help g-only", text: WORDS.sugHelp }), ui.sugList); + ui.mineList = el("ul", { class: "my-sugs" }); + ui.mineBox = el("section", { class: "box area", "data-panel": "mine", "aria-label": WORDS.mySuggestionsHeading, hidden: true }, + areaHead(WORDS.mySuggestionsHeading), el("p", { class: "area-help g-only", text: WORDS.mineHelp }), ui.mineList); + ui.inviteBody = el("div", { class: "invite-body" }); + ui.inviteBox = el("section", { class: "box area invite-box", "data-panel": "invite", "aria-label": WORDS.inviteHeading, hidden: true }, + areaHead(WORDS.inviteSomeone), el("p", { class: "area-help g-only", text: WORDS.inviteHelp }), ui.inviteBody); + // The guest's suggestion: a card in Guided, a popover in Shared. + ui.quote = el("div", { class: "quote" }); + ui.replacement = el("textarea", { id: "replacement", maxlength: String(MAX_REPLACEMENT) }); + ui.suggestForm = el("form", { class: "suggest-form", hidden: true, onsubmit: (e) => { e.preventDefault(); if (sendSuggestion(ui.replacement.value)) { ui.suggestForm.hidden = true; applyLayout(); } } }, + el("div", { class: "field-group" }, el("span", { class: "field", text: WORDS.youSelected }), ui.quote), + el("div", { class: "field-group" }, el("label", { for: "replacement", text: WORDS.replacementLabel }), ui.replacement), + el("div", { class: "row" }, + el("button", { type: "submit", class: "btn primary", text: WORDS.send }), + el("button", { type: "button", class: "btn", text: WORDS.cancel, onclick: () => { ui.suggestForm.hidden = true; applyLayout(); } }))); + ui.suggestButton = el("button", { type: "button", class: "btn primary", onclick: () => openSuggestForm() }, icon("pencil"), WORDS.suggest); + ui.suggestBox = el("section", { class: "box area guest-panel", "data-panel": "suggest", "aria-label": WORDS.suggest, hidden: true }, + areaHead(WORDS.suggest), el("h3", { class: "s-only pop-title", text: WORDS.suggest }), + el("p", { class: "area-help g-only", text: WORDS.guestHint }), + el("div", { class: "row g-only" }, ui.suggestButton), ui.suggestForm); + + // The guided workspace: a banner, then the documents, the brief and the + // numbered areas, and the way to GitHub. + ui.bannerTitle = el("h1", { tabindex: "-1" }); + ui.bannerHelp = el("p"); + ui.gDocs = el("div", { class: "docs-slot" }); + ui.gPaper = el("div", { class: "paper" }); + ui.readonlyTag = el("div", { class: "readonly-tag" }, icon("eye"), WORDS.readOnly); + ui.briefHelp = el("p", { class: "area-help" }); + ui.editHint = el("p", { class: "edit-hint" }, icon("check"), WORDS.savedForEveryone); + ui.gSideTitle = el("h2"); + ui.gSide = el("aside", { class: "side" }, + el("div", { class: "inspector-head" }, ui.gSideTitle, + el("button", { type: "button", class: "btn small", text: WORDS.close, onclick: () => toggleSide("right") }))); + ui.submitG = el("button", { type: "button", class: "btn primary", onclick: () => submitSession() }, icon("send"), WORDS.submitMore); + ui.finish = el("section", { class: "card finish full area", "aria-label": WORDS.sendHeading }, + el("div", {}, el("h2", { class: "area-title" }, el("span", { class: "num", "aria-hidden": "true" }), el("span", { text: WORDS.finishHeading })), + el("p", { class: "area-help", text: WORDS.finishHelp })), + el("div", { class: "actions" }, ui.submitG)); + ui.nextLive = el("div", { class: "next g-only", role: "note" }, + el("h2", {}, icon("next"), WORDS.whatNext), + el("p", { class: "one", text: WORDS.nextGuest })); + ui.tabs = [ + ["", "doc", WORDS.tabBrief, "all"], ["sugs", "bubble", WORDS.sugShort, "member"], ["suggest", "pencil", WORDS.tabSuggest, "guest"], + ["mine", "clock", WORDS.tabSent, "guest"], ["people", "people", WORDS.tabPeople, "all"], ["invite", "invite", WORDS.tabInvite, "host"], + ].map(([sheet, ic, text, who]) => el("button", { type: "button", class: "tab", "data-sheet": sheet, "data-who": who, onclick: () => openSheet(sheet) }, + icon(ic), el("span", { text }))); + ui.tabCount = el("b", { class: "tab-count", "aria-hidden": "true" }); + ui.tabs[1].append(ui.tabCount); + ui.gDocsCard = el("nav", { class: "card docs-card", "aria-label": WORDS.documents }, + el("h2", { class: "side-title" }, icon("doc"), el("span", { text: WORDS.documents })), + el("p", { class: "hint", text: WORDS.documentsHint }), ui.gDocs); + // A guided phone's sheets: the right side's panels, one open at a time. + ui.sheets = [ui.suggestBox, ui.sugBox, ui.mineBox, ui.rosterBox, ui.inviteBox]; + const guided = el("div", { class: "g-live g-only" }, + el("section", { class: "card banner" }, el("div", {}, ui.bannerTitle, ui.bannerHelp), el("div", { class: "banner-acts" }, ui.liveDot)), + el("div", { class: "live-grid" }, + ui.gDocsCard, + el("section", { class: "card brief-card area", "aria-label": WORDS.briefArea }, + el("div", { class: "card-head" }, el("h2", { class: "area-title" }, el("span", { class: "num", "aria-hidden": "true" }), el("span", { text: WORDS.briefArea }))), + ui.briefHelp, ui.readonlyTag, ui.gPaper, ui.editHint), + ui.gSide, ui.finish), + ui.nextLive, + el("nav", { class: "tabbar", "aria-label": WORDS.sessionBar }, ...ui.tabs)); + + // The shared workspace: a floating toolbar over the document, a sidebar + // of documents and people, an inspector of suggestions, and overlays. + ui.sDocs = el("div", { class: "docs-slot" }); + ui.sPeople = el("div", { class: "people-slot" }); + ui.sbInfo = el("p", { class: "sb-info" }); + ui.sFoot = el("div", { class: "sb-foot" }); + ui.sSidebar = el("nav", { class: "sidebar glass", id: "s-docs", "aria-label": WORDS.documents }, + el("div", { class: "sb-head" }, el("span", { class: "appicon sm" }, icon("brief")), + el("span", {}, el("b", { text: repo }), el("small", { text: say(WORDS.brandSub, { owner: route.owner }) }))), + el("h2", { class: "sb-ttl", text: repo }), + closeButton(() => toggleSide("left")), + el("p", { class: "sb-label", text: WORDS.documents }), ui.sDocs, + el("p", { class: "sb-label", text: WORDS.hereNow }), ui.sPeople, + el("p", { class: "sb-label", text: WORDS.thisSession }), ui.sbInfo, ui.sFoot); + ui.guestBanner = el("p", { class: "banner" }, icon("bubble"), el("span", { text: WORDS.guestHint })); + ui.sDoc = el("div", { class: "doc-slot" }); + ui.tiles = el("ul", { class: "tiles" }); + ui.hudSum = el("button", { type: "button", class: "hud-sum", onclick: () => { if (!sideShown("left")) toggleSide("left"); } }); + ui.inviteToggle = el("button", { type: "button", class: "ibtn hud-inv tip-r", "aria-label": WORDS.invitePeople, "aria-haspopup": "dialog", "data-tip": WORDS.invitePeople, "data-key": "", onclick: () => toggleInvite() }, icon("invite")); + ui.hud = el("div", { class: "hud glass", role: "group", "aria-label": WORDS.whoIsHere }, ui.hudLive, ui.tiles, ui.hudSum, ui.inviteToggle); + ui.inspTitle = el("h2"); + ui.inspCount = el("span", { class: "count" }); + ui.inspSub = el("p", { class: "insp-sub" }); + ui.sInsp = el("div", { class: "insp-list" }); + ui.sPop = el("div", { class: "popover glass-strong", role: "dialog", "aria-label": WORDS.suggest }, closeButton(() => { ui.suggestForm.hidden = true; applyLayout(); })); + ui.shareClose = closeButton(() => closeInvite()); + ui.sShare = el("div", { class: "share glass-strong", role: "dialog", "aria-label": WORDS.invitePeople }, + ui.shareClose, + el("div", { class: "share-h" }, el("span", { class: "appicon sm" }, icon("invite")), + el("div", {}, el("b", { text: WORDS.inviteTitle }), el("small", { text: say(WORDS.brandSub, { owner: route.owner }) })))); + ui.sAside = el("aside", { class: "inspector glass", id: "s-side", "aria-label": WORDS.suggestionsHeading }, + closeButton(() => toggleSide("right")), + el("div", { class: "insp-h" }, ui.inspTitle, ui.inspCount), ui.inspSub, ui.sInsp); + // In the order it shows, so the tab order follows the eye: the toolbar + // along the top, the documents, the brief, the suggestions. + const shared = el("div", { class: "s-live s-only" }, + el("div", { class: "toolbar", role: "toolbar", "aria-label": WORDS.sessionBar }, + el("div", { class: "tcap tc-left glass" }, ui.sLeft), ui.hud, ui.submit, ui.suggestTop, + el("div", { class: "tcap tc-right glass" }, ui.sRight)), + ui.sSidebar, + el("div", { class: "main" }, el("div", { class: "docscroll" }, el("article", { class: "doc" }, ui.guestBanner, ui.sDoc))), + ui.sAside, + ui.sPop, ui.sShare, + el("div", { class: "scrim", onclick: () => closeTop() })); + + // Sending the session to GitHub: a screen of its own in Guided, a sheet + // over the session in Shared. + ui.sendHeading = el("h2", { id: "send-heading", tabindex: "-1", text: WORDS.sendHeading }); + ui.sendIntro = el("p", { class: "state" }); + ui.sendList = el("ul", { class: "changes" }); + ui.sendActions = el("div", { class: "row actions" }); + ui.sendResult = el("p", { class: "state", role: "status", "aria-live": "polite" }); + ui.send = el("section", { class: "send glass-strong", hidden: true, role: "dialog", "aria-labelledby": "send-heading" }, + closeButton(() => closeSend()), + el("div", { class: "send-in card" }, + el("div", { class: "send-h" }, el("span", { class: "appicon sm soft s-only" }, icon("send")), + el("div", {}, ui.sendHeading, ui.sendIntro)), + el("h3", { text: WORDS.whatChanged }), ui.sendList), + el("div", { class: "send-foot card" }, + el("div", { class: "reassure" }, icon("shield"), el("p", { text: WORDS.sendReassure })), + ui.sendActions, ui.sendResult)); + ui.work = el("div", { class: "work", hidden: true }, guided, shared, ui.send); + + // The confirmation a relay the repository's list cannot speak for needs + // before any sign-in, name or suggestion reaches it, shown over the start + // screen or a session. It names the project and no address. + ui.trustOwner = el("p", { class: "owner" }); + ui.trustCancel = el("button", { type: "button", class: "btn", text: WORDS.cancel, onclick: () => trustAnswered(false) }); + ui.trust = el("section", { class: "card trust glass-strong", hidden: true, role: "alertdialog", "aria-labelledby": "trust-heading" }, + el("div", { class: "s-only" }, closeButton(() => trustAnswered(false))), + el("h2", { id: "trust-heading", text: WORDS.trustHeading }), + ui.trustOwner, el("p", { text: WORDS.trustQuestion }), el("p", { class: "only", text: WORDS.trustOnly }), + el("div", { class: "row" }, + el("button", { type: "button", class: "btn primary", text: WORDS.trustYes, onclick: () => trustAnswered(true) }), ui.trustCancel)); + + app.append(bar, ui.trust, ui.device, ui.start, ui.work, ui.toast); + window.addEventListener("keydown", onKey, true); + let resizing = 0; + window.addEventListener("resize", () => { + cancelAnimationFrame(resizing); + resizing = requestAnimationFrame(() => applyLayout()); + }); + if (typeof ResizeObserver === "function") new ResizeObserver(() => fitTiles()).observe(ui.hud); + applyLayout(); + return true; +} + +// readStartForm takes the code and the name from the start screen. +function readStartForm() { + session.code = linkCode || normalizeCode(ui.code.value); + session.name = ui.name.value.trim().slice(0, 40); +} + +function joinClicked() { + if (!session.relay) { toast(standingRefusal("")); return; } + const code = linkCode || normalizeCode(ui.code.value); + if (!code) { toast(WORDS.codeBad); ui.code.focus(); return; } + // A guest's name reaches a relay only once the relay is cleared, as a + // sign-in does: its own origin, listed by the repository, or confirmed. + if (!relayClearedFor("join")) return; + session.code = code; + session.name = ui.name.value.trim().slice(0, 40); + if (session.name) remember(KEY_NAME, session.name); + if (socketOpen()) proceed(); + else openSocket(); +} + +function hostClicked() { + if (!relayClearedFor("host")) return; + session.code = ""; + if (socketOpen() && session.member) sendJSON({ type: "host", repo: route.slug }); + else { session.pending = "host"; openSocket(); } +} + +function openSuggestForm() { + if (!view) return; + const sel = view.state.selection.main; + if (sel.empty) { toast(WORDS.selectFirst); return; } + const text = view.state.sliceDoc(sel.from, sel.to); + ui.quote.textContent = text; + ui.replacement.value = text; + ui.suggestForm.hidden = false; + if (viewWidth() <= PHONE_MAX) { + if (design === "guided") layout.sheet = "suggest"; + else { layout.pleft = false; layout.pright = false; } + } + applyLayout(); + ui.replacement.focus(); +} + +// renderTop shows who this is, the sign-in a guest may still make, and the +// way to GitHub, in each design's place for them. +function renderTop() { + if (!ui.gMe) return; + let who = ""; + if (session.login) who = session.login; + else if (session.inRoom && session.name) who = session.name; + const me = session.roster.find((p) => p.id === session.myId); + clear(ui.gMe); + clear(ui.sFoot); + if (who) { + ui.gMe.append(avatar(who, session.myId, "md"), el("span", { class: "who", text: session.login ? WORDS.signedInAs + " " + who : who }), me ? badge(me) : null); + ui.sFoot.append(avatar(who, session.myId, "sm"), + el("span", {}, who, el("small", { text: session.role === "guest" ? WORDS.joinedAsGuest : WORDS.signedInShort + (session.host ? " · " + WORDS.badgeHost : "") }))); + } + ui.signInTop.hidden = !(session.inRoom && session.role === "guest" && !session.over) || signInOffered === false; + (design === "shared" ? ui.sFoot : ui.gMe).append(ui.signInTop); + ui.submit.hidden = !(session.synced && session.role === "member" && !session.over); + ui.finish.hidden = ui.submit.hidden; + ui.suggestTop.hidden = !(session.synced && session.role === "guest" && !session.over); + ui.sbInfo.textContent = session.role === "guest" ? WORDS.sessionInfoGuest : WORDS.sessionInfoMember; + const name = session.name || WORDS.badgeGuest; + ui.bannerTitle.textContent = session.role === "guest" ? say(WORDS.bannerGuest, { name, repo: route.repo }) + : say(session.host ? WORDS.bannerHost : WORDS.bannerMember, { repo: route.repo }); + ui.bannerHelp.textContent = session.role === "guest" ? WORDS.bannerGuestHelp : WORDS.bannerMemberHelp; + ui.briefHelp.textContent = session.role === "guest" ? WORDS.readHelp : WORDS.editHelp; + ui.readonlyTag.hidden = session.role === "member"; + ui.editHint.hidden = session.role !== "member"; + ui.guestBanner.hidden = session.role !== "guest" || session.over; + ui.nextLive.hidden = session.role !== "guest"; + paintStatus(); +} + +function renderStart() { + if (!ui.start) return; + const inSession = session.everJoined || session.over; + ui.start.hidden = inSession; + ui.work.hidden = !inSession || session.seeding; + ui.app.dataset.screen = ui.work.hidden ? "start" : "work"; + if (inSession) return; + // What stands in the way, in plain words and with no address: no session + // set up, a link that is not the project's own, or GitHub out of reach. + const standing = relayStanding(session.relay); + let notice = ""; + if (!session.relay) { + notice = { checking: WORDS.checking, unavailable: WORDS.cannotCheck, unknown: WORDS.privateNoLink }[anchor.state] || WORDS.notSetUp; + } else if (standing === "refused" || standing === "unavailable" || standing === "checking") { + notice = standingRefusal(standing); + } + ui.notice.textContent = notice; + ui.noticeCard.hidden = !notice; + ui.retry.hidden = anchor.state !== "unavailable"; + const notSetUp = notice === WORDS.notSetUp; + ui.askCard.hidden = !notSetUp; + ui.askMessage.textContent = say(WORDS.askMessage, { slug: route.slug, page: pageURL() }); + const usable = !notice; + const invited = !!linkCode; + const canSignIn = !session.login && signInOffered !== false; + ui.heroTitleGuided.textContent = say(invited ? WORDS.invitedTitle : WORDS.heroTitle, { repo: route.repo }); + ui.heroTitleShared.textContent = say(invited ? WORDS.invitedTitle : WORDS.sharedTitle, { repo: route.repo }); + ui.heroLede.textContent = invited ? WORDS.invitedLede : say(WORDS.heroLede, { repo: route.repo }); + ui.heroLedeShared.textContent = invited ? WORDS.invitedLede : say(WORDS.heroLedeShared, { repo: route.repo }); + ui.signedIn.textContent = session.login + ? WORDS.signedInAs + " " + session.login + (session.member ? "" : ". " + WORDS.notMember) + : ""; + ui.signInNote.textContent = usable && signInOffered === false ? WORDS.signInNotSetUp : ""; + ui.startCard.hidden = !usable || invited; + ui.signInStart.hidden = !canSignIn; + ui.hostRow.hidden = !(session.login && session.member); + ui.joinCard.hidden = !usable; + ui.joinTitle.textContent = invited ? WORDS.join : WORDS.haveCode; + ui.joinMembers.hidden = !usable || !invited || !canSignIn; + ui.joinGuests.hidden = !invited; + ui.codeHint.hidden = invited; + ui.codeRow.hidden = invited; + ui.joinButton.textContent = invited ? WORDS.join : WORDS.joinWithCode; + // What happens next, in the guided design: the steps from here, or what + // follows once sessions are set up. + clear(ui.nextStart); + ui.nextStart.hidden = !(usable || notSetUp); + ui.nextStart.append(el("h2", {}, icon("next"), WORDS.whatNext)); + if (notSetUp) ui.nextStart.append(el("p", { class: "one", text: WORDS.nextNotSetUp })); + else if (invited) ui.nextStart.append(el("p", { class: "one", text: WORDS.nextInvited })); + else { + ui.nextStart.append(nextSteps([[WORDS.nextSignIn, WORDS.nextHere, "here"], [WORDS.nextJoin, WORDS.nextJoinHint], + [WORDS.nextWork, WORDS.nextWorkHint], [WORDS.nextSend, WORDS.nextSendHint]])); + } +} + +function renderFiles() { + if (!ui.files) return; + clear(ui.files); + const paths = [...files.keys()].sort(); + let folder = null; + for (const p of paths) { + const rel = p.startsWith(BRIEF_PREFIX) ? p.slice(BRIEF_PREFIX.length) : p; + const cut = rel.lastIndexOf("/"); + const dir = cut < 0 ? "" : rel.slice(0, cut); + if (dir !== folder) { + folder = dir; + if (dir) ui.files.append(el("div", { class: "folder", text: dir + "/" })); + } + ui.files.append(el("button", { + type: "button", + class: "doc-tab", + title: rel, + "aria-current": p === currentPath ? "true" : "false", + onclick: () => { openFile(p); if (viewWidth() <= PHONE_MAX && design === "shared" && layout.pleft) { layout.pleft = false; applyLayout(); } }, + }, icon(fileIcon(p)), el("b", { class: "t", text: prettyName(p) }), el("small", { text: cut < 0 ? rel : rel.slice(cut + 1) }), icon("right", "chev"))); + } +} + +function renderGuestBar() { + if (!ui.suggestBox) return; + const guest = session.role === "guest" && session.synced && !session.over && !!view; + ui.suggestBox.hidden = !guest; + if (!guest && design === "shared") ui.suggestForm.hidden = true; + if (guest) ui.suggestButton.disabled = view.state.selection.main.empty; +} + +function badge(p) { + if (p.host) return el("span", { class: "badge host", text: WORDS.badgeHost }); + return el("span", { class: p.role === "member" ? "badge member" : "badge guest", text: p.role === "member" ? WORDS.badgeMember : WORDS.badgeGuest }); +} + +function renderSide() { + if (!ui.app) return; + clear(ui.notesList); + for (const n of notes) ui.notesList.append(el("li", { text: n })); + ui.notesBox.hidden = notes.length === 0; + + const hosting = session.host && session.code && !session.seeding && !session.over; + ui.inviteBox.hidden = !hosting; + ui.inviteToggle.hidden = !hosting; + if (!hosting) layout.invite = false; + clear(ui.inviteBody); + if (hosting) { + const url = inviteURL(); + let img = null; + try { + const qr = qrcode(0, "M"); + qr.addData(url); + qr.make(); + img = el("img", { class: "qr", src: qr.createDataURL(6, 2), alt: WORDS.inviteHint }); + } catch { img = null; } + const mode = el("select", { id: "mode-live", onchange: (e) => { doc.transact(() => meta.set("mode", e.target.value)); } }, + el("option", { value: "pr", text: WORDS.modePR }), el("option", { value: "issues", text: WORDS.modeIssues })); + mode.value = effectiveMeta().mode === "issues" ? "issues" : "pr"; + ui.inviteBody.append( + el("div", { class: "invite" }, + el("div", { class: "qr-wrap" }, img, el("span", { class: "qr-cap s-only", text: WORDS.scanHint })), + el("div", { class: "stack" }, + el("span", { class: "hint", text: WORDS.joinCode }), + el("div", { class: "code joincode", "aria-label": WORDS.codeLabel + ": " + [...session.code].join(" ") }, + ...[...session.code].map((c) => el("span", { "aria-hidden": "true", text: c }))), + el("p", { class: "code-hint", text: WORDS.inviteHint }), + ui.copyLink = el("button", { type: "button", class: "btn small primary", onclick: () => copyLink(url) }, icon("copy"), WORDS.copyLink))), + el("div", { class: "targets" }, + el("button", { type: "button", class: "btn small", onclick: () => copyText(session.code, WORDS.codeCopied) }, icon("copy"), WORDS.copyCode), + el("button", { type: "button", class: "btn small", onclick: () => sendJSON({ type: "rotate" }) }, icon("refresh"), WORDS.newCode), + el("button", { type: "button", class: "btn small danger", onclick: () => { if (confirm(WORDS.endConfirm)) sendJSON({ type: "close" }); } }, icon("x"), WORDS.endSession)), + el("div", { class: "field-group" }, el("label", { for: "mode-live", text: WORDS.modeLabel }), mode)); + } + + // Who is here: a list in each design, and the tiles of the shared team bar. + clear(ui.people); + clear(ui.tiles); + clear(ui.hudSum); + const stack = el("span", { class: "stack" }); + for (const p of session.roster) { + const name = p.name || p.login || ""; + const isMe = p.id === session.myId; + ui.people.append(el("li", { class: isMe ? "person me" : "person" }, + avatar(name, p.id), el("span", { class: "nm" }, name, isMe ? " " + WORDS.you : ""), badge(p))); + ui.tiles.append(el("li", { class: isMe ? "tile me" : "tile" }, avatar(name, p.id), + el("span", { class: "tile-n" }, isMe ? WORDS.youShort : name, badge(p)))); + if (stack.childElementCount < 4) stack.append(avatar(name, p.id, "xs")); + } + const here = say(WORDS.hereCount, { n: session.roster.length }); + ui.hudSum.append(stack, el("span", { class: "txt" }, el("b", { text: here })), icon("right")); + ui.hudSum.setAttribute("aria-label", here + ". " + WORDS.showDocuments); + + // The suggestions waiting for a member, and a guest's own. + const member = session.role === "member" && session.synced; + ui.sugBox.hidden = !member; + clear(ui.sugList); + let waiting = 0; + if (member) { + for (const s of suggestions.values()) { + if (!s || typeof s.id !== "string" || suggestionsDone.has(s.id) || !validSuggestion(s.body)) continue; + waiting++; + ui.sugList.append(el("article", { class: "sug" }, + el("div", { class: "sug-h" }, avatar(s.from, "", "sm guest-av"), + el("div", { class: "who" }, el("b", {}, WORDS.suggestedBy + " " + s.from, " ", el("span", { class: "badge guest", text: WORDS.badgeGuest })), + el("small", { text: prettyName(s.body.path) + " · " + s.body.path.replace(BRIEF_PREFIX, "") }))), + el("div", { class: "diff" }, + el("div", { class: "old" }, el("span", { class: "k" }, el("span", { class: "g-only", text: WORDS.wordsNow }), el("span", { class: "s-only", text: WORDS.wordsWas })), el("del", { text: s.body.quote.exact })), + el("div", { class: "new" }, el("span", { class: "k" }, el("span", { class: "g-only", text: WORDS.wordsSuggested }), el("span", { class: "s-only", text: WORDS.wordsNow })), el("ins", { text: s.body.replacement }))), + el("div", { class: "acts" }, + el("button", { type: "button", class: "btn small primary apply", text: WORDS.apply, onclick: () => applySuggestion(s.id) }), + el("button", { type: "button", class: "btn small decline", text: WORDS.decline, onclick: () => declineSuggestion(s.id) })))); + } + if (!waiting) ui.sugList.append(el("p", { class: "state empty", text: WORDS.noSuggestions })); + } + + const guest = session.role === "guest"; + ui.mineBox.hidden = !guest || !session.synced; + clear(ui.mineList); + if (guest) { + for (const s of mine) { + const done = suggestionsDone.get(s.id); + const [cls, ic, word] = done === "applied" ? ["ok", "check", WORDS.applied] : done === "declined" ? ["no", "x", WORDS.declined] : ["wait", "clock", WORDS.waiting]; + ui.mineList.append(el("li", { class: "mine-item" }, + el("div", { class: "mine-h" }, el("b", { text: prettyName(s.body.path || "") }), el("span", { class: "status " + cls }, icon(ic), word)), + el("del", { text: s.body.quote.exact }), + el("ins", { text: s.body.replacement }))); + } + if (!mine.length) ui.mineList.append(el("li", { class: "state empty", text: WORDS.noMine })); + } + + // The counts and the right sidebar's names, for this role. + const count = member ? waiting : guest ? mine.length : 0; + for (const c of [ui.sugCount, ui.inspCount, ui.rightCount, ui.tabCount]) { + c.textContent = String(count); + c.hidden = count === 0; + } + ui.inspTitle.textContent = guest ? WORDS.mySuggestionsHeading : WORDS.sugWaiting; + ui.inspSub.textContent = guest ? WORDS.mineSub : WORDS.sugSub; + for (const tab of ui.tabs) { + const who = tab.dataset.who; + tab.hidden = !(who === "all" || (who === "member" && session.role === "member") || (who === "guest" && guest) || (who === "host" && hosting)); + } + paintStatus(); + fitTiles(); +} + +async function copyLink(url) { + await copyText(url, WORDS.linkCopied); +} +async function copyText(text, done) { + try { await navigator.clipboard.writeText(text); toast(done); } catch { /* clipboard refused */ } +} + +function renderAll() { + if (session.over && ui.send) ui.send.hidden = true; + renderTop(); + renderStart(); + renderSide(); + renderGuestBar(); + applyLayout(); + if (session.synced && !session.seeding && !view && files.size > 0) { + openFile(firstFile()); + } else if (view && session.over) { + view.destroy(); + view = null; + } +} + +// --------------------------------------------------------------------------- +// Start. +// --------------------------------------------------------------------------- +// addSlash sends ///brief on to ///brief/, the +// address the page's links and the invite are made from. +function addSlash() { + if (!/\/[^/]+\/[^/]+\/brief$/.test(location.pathname)) return false; + location.replace(location.pathname + "/" + location.search + location.hash); + return true; +} + +// framed says the page is inside another page's frame. A static host cannot +// send frame-ancestors (or X-Frame-Options), so the page refuses a frame +// itself: framed, it could be overlaid to steal a click on the relay's +// confirmation or the sign-in. +function framed() { + try { return window.top !== window.self; } catch { return true; } +} +// refuseFramed shows only a message and a link that opens the page in its +// own tab: no trust confirmation, no sign-in, no session. +function refuseFramed() { + const app = document.getElementById("app"); + clear(app); + app.append(el("p", { class: "notice" }, WORDS.framedRefused + " ", + el("a", { href: pageURL(), target: "_top", rel: "noopener noreferrer", text: pageURL() }))); +} + +// The relay and the code an invite link carries in its fragment, which never +// reaches a server. +let linkRelay = ""; +let linkCode = ""; + +async function main() { + if (addSlash()) return; + if (framed()) { refuseFramed(); return; } + if (!build()) return; + // A confirmation an earlier page kept in local storage, for good, is + // dropped: one now lasts this tab, and a bounded time. + try { localStorage.removeItem(LEGACY_TRUSTED); } catch { /* storage unavailable */ } + // Leaving the page ends a device sign-in under way on the relay too. + addEventListener("pagehide", () => stopDeviceSignIn(true)); + const frag = new URLSearchParams(location.hash.slice(1)); + linkRelay = relayOrigin(frag.get("relay")); + linkCode = normalizeCode(frag.get("code")); + session.code = linkCode; + session.name = recall(KEY_NAME); + ui.code.value = session.code; + ui.name.value = session.name; + setStatus("", false); + renderAll(); + // Before anything reaches a relay: is the relay this page's own server, + // and if not, which relays does the repository name? + anchor = selfServed() ? { state: "self", relays: [] } : await readRelayList(""); + session.relay = chooseRelay(linkRelay); + renderAll(); + refreshSignIn(); + if (!(await finishSignIn())) { + // A reload of a page that was in a session: back in by its rejoin token. + if (takeRejoin()) openSocket(); + } + ui.code.value = session.code; + renderAll(); +} +main(); diff --git a/internal/surface/session/web/signin_bind_test.go b/internal/surface/session/web/signin_bind_test.go new file mode 100644 index 000000000..12ff7a183 --- /dev/null +++ b/internal/surface/session/web/signin_bind_test.go @@ -0,0 +1,287 @@ +package web + +import ( + "regexp" + "strings" + "testing" +) + +// The web sign-in's code goes only to the relay GitHub returned the person +// to: the team's app lists the relay's own callback, the relay sends the +// person on to the page naming itself, and the page posts the code to that +// relay alone, never to one the invite link or the tab's storage names. A +// relay the person confirmed, where the repository's list cannot be read, +// may run the sign-in: GitHub sends the code only to a callback the team's +// app lists. The token then goes for verification only to the relay GitHub +// returned the person to. + +// pageHarness is the page's sign-in, storage and trust functions, with the +// browser around them stubbed: storage, location, history and fetch, which +// records every request. +func pageHarness(t *testing.T) string { + js := pageScript(t) + var b strings.Builder + b.WriteString(` +const store = () => { const m = new Map(); return { getItem: (k) => m.has(k) ? m.get(k) : null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m }; }; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const WORDS = new Proxy({}, { get: (_, k) => String(k) }); +const toasts = []; +function toast(text) { toasts.push(text); } +function renderAll() {} +function setStatus() {} +function updateFragment() {} +function framed() { return false; } +function readStartForm() {} +function askTrust(action) { toasts.push("askTrust:" + action); } +function recheckRelayList() {} +function startDeviceSignIn() { toasts.push("device"); } +function takeRejoin() { return false; } +async function readRelayList() { return { state: anchor.state, relays: anchor.relays }; } +const sent = []; +let socket = false; +function socketOpen() { return socket; } +function sendJSON(msg) { sent.push({ relay: session.relay, msg }); } +function openSocket() { sent.push({ relay: session.relay, msg: { type: "open" } }); } +let heldToken = ""; +let githubToken = ""; +let tokenRelay = ""; +let signInOffered = null; +const requests = []; +let assigned = ""; +const location = { origin: "https://abcdesign.app", pathname: "/acme/brief/brief/", search: "", hash: "", assign: (u) => { assigned = u; } }; +globalThis.history = { replaceState: () => {} }; +const route = { slug: "acme/brief" }; +const session = { relay: "", code: "", name: "", everJoined: false, inRoom: false, verifying: false, rejoining: false }; +const rejoinToken = ""; +function proceed() {} +let anchor = { state: "listed", relays: [] }; +let config = { client_id: "Iv1.abc", sign_in: true, app_type: "github-app", authorize_url: "https://github.com/login/oauth/authorize", flow: "web" }; +globalThis.fetch = async (url, init) => { + requests.push({ url, body: init && init.body ? JSON.parse(init.body) : null }); + if (url.endsWith("/config")) return { ok: true, status: 200, json: async () => config }; + return { ok: true, status: 200, json: async () => ({ access_token: "ghu_x" }) }; +}; +`) + // Each piece is taken when session.js has it, so the harness runs the + // page as it stands; a test asserts on what the page does. + for _, c := range []string{"GITHUB_AUTHORIZE", "KEY_SIGN_IN", "KEY_TRUSTED", "CONFIRM_LIFE", "CALLBACK_PATH", "CODE_ALPHABET"} { + if regexp.MustCompile(`(?m)^const ` + c + ` = `).MatchString(js) { + b.WriteString(constLine(t, js, c) + "\n") + } + } + for _, fn := range []string{ + "recall", "remember", "stashSignIn", "takeSignIn", "trustedRelays", "keepTrusted", "liveConfirmations", "confirmedRelays", "confirmRelay", "forgetRelay", + "pageURL", "relayOrigin", "normalizeCode", "b64url", "randomText", "isLoopbackOrigin", "relayConfig", + "relayStanding", "relayCleared", "relayTokenCleared", "standingRefusal", "relayClearedFor", + "startSignIn", "finishSignIn", "presentHeldToken", "connectSignedIn", "afterOpen", + } { + if regexp.MustCompile(`(?m)^(?:async )?function ` + fn + `\(`).MatchString(js) { + b.WriteString(functionBody(t, js, fn) + "\n") + } + } + return b.String() +} + +func TestASignInCodeGoesOnlyToTheRelayGitHubReturnedTo(t *testing.T) { + const relay, other = "https://brief-relay.onrender.com", "https://other-relay.example.org" + script := pageHarness(t) + ` +const pending = (r) => ({ state: "s1.cGFnZQ", verifier: "v".repeat(43), relay: r, code: "", name: "" }); +async function attempt(name, { stored, returned, listed, confirmed, anchorState, linked }) { + sessionStorage.m.clear(); + requests.length = 0; toasts.length = 0; sent.length = 0; heldToken = ""; githubToken = ""; tokenRelay = ""; + anchor = { state: anchorState || "listed", relays: listed || [] }; + // The relay the invite link named, which main chose before the return. + session.relay = linked || ""; + if (confirmed) confirmRelay(confirmed); + sessionStorage.setItem(KEY_SIGN_IN, JSON.stringify(pending(stored))); + const q = new URLSearchParams({ code: "good-code", state: "s1.cGFnZQ" }); + if (returned !== undefined) q.set("relay", returned); + location.search = "?" + q.toString(); + await finishSignIn(); + const posts = requests.filter((r) => r.url.endsWith("/auth/token")); + out[name] = { + posts: posts.map((p) => ({ url: p.url, redirect: p.body.redirect_uri })), + verified: sent.filter((x) => x.msg.type === "open" || x.msg.type === "verify").map((x) => x.relay), + }; +} +const out = {}; +(async () => { + await attempt("returned", { stored: "` + relay + `", returned: "` + relay + `", listed: ["` + relay + `"] }); + await attempt("no-relay", { stored: "` + relay + `", listed: ["` + relay + `"] }); + await attempt("another-relay", { stored: "` + relay + `", returned: "` + other + `", listed: ["` + relay + `", "` + other + `"] }); + await attempt("stored-differs", { stored: "` + other + `", returned: "` + relay + `", listed: ["` + relay + `", "` + other + `"] }); + await attempt("unlisted", { stored: "` + other + `", returned: "` + other + `", listed: ["` + relay + `"] }); + await attempt("confirmed", { stored: "` + other + `", returned: "` + other + `", anchorState: "unknown", confirmed: "` + other + `" }); + // The invite link named another relay than the one GitHub returned to. + await attempt("link-differs", { stored: "` + relay + `", returned: "` + relay + `", anchorState: "unknown", confirmed: "` + relay + `", linked: "` + other + `" }); + console.log(JSON.stringify(out)); +})(); +` + type result struct { + Posts []struct{ URL, Redirect string } + Verified []string + } + var out map[string]result + runPage(t, script, &out) + // The code, and then the token, go to the relay GitHub returned to: a + // listed one, a confirmed one, and one other than the invite link named. + for name, to := range map[string]string{"returned": relay, "confirmed": other, "link-differs": relay} { + got := out[name] + if len(got.Posts) != 1 || got.Posts[0].URL != to+"/auth/token" || got.Posts[0].Redirect != to+"/auth/callback" { + t.Errorf("%s: posts %+v, want one post to %s/auth/token naming its callback", name, got.Posts, to) + } + if len(got.Verified) != 1 || got.Verified[0] != to { + t.Errorf("%s: the token went to %v, want %s alone", name, got.Verified, to) + } + } + for name, why := range map[string]string{ + "no-relay": "a code arriving with no relay named", + "another-relay": "a code returned through a relay other than the one the sign-in started with", + "stored-differs": "a code whose stored relay differs from the one GitHub returned to", + "unlisted": "a relay the repository's list does not name", + } { + if c := out[name]; len(c.Posts) != 0 || len(c.Verified) != 0 { + t.Errorf("%s: %+v, want no code and no token sent anywhere", why, c) + } + } +} + +// The token is presented for verification only to the relay it was signed +// in through: never to a relay the invite link named that GitHub did not +// return the person to, whichever way the page would present it. +func TestTheTokenGoesOnlyToTheRelayItWasSignedInThrough(t *testing.T) { + const relay, other = "https://brief-relay.onrender.com", "https://other-relay.example.org" + script := pageHarness(t) + ` +const out = {}; +(async () => { + anchor = { state: "unknown", relays: [] }; + confirmRelay("` + relay + `"); + confirmRelay("` + other + `"); + tokenRelay = "` + relay + `"; + // A held token, with the page pointed at the link's relay. + session.relay = "` + other + `"; + heldToken = "ghu_x"; + await presentHeldToken(false); + out.present = { sent: sent.slice(), token: githubToken, held: heldToken }; + // A token already in hand, on an open connection and on a new one. + githubToken = "ghu_x"; sent.length = 0; + socket = true; + connectSignedIn(); + out.connect = sent.length; + githubToken = "ghu_x"; sent.length = 0; + afterOpen(); + out.open = sent.filter((x) => x.msg.type === "verify").length; + // Pointed back at the relay it was signed in through, it goes there. + session.relay = "` + relay + `"; githubToken = "ghu_x"; sent.length = 0; + connectSignedIn(); + out.own = sent.map((x) => x.relay + ":" + x.msg.type); + console.log(JSON.stringify(out)); +})(); +` + var out struct { + Present struct { + Sent []any + Token, Held string + } + Connect, Open int + Own []string + } + runPage(t, script, &out) + if len(out.Present.Sent) != 0 || out.Present.Token != "" || out.Present.Held != "" { + t.Errorf("a held token with the page on the link's relay: %+v, want it dropped and nothing sent", out.Present) + } + if out.Connect != 0 || out.Open != 0 { + t.Errorf("a token in hand reached the link's relay: connect %d, open %d", out.Connect, out.Open) + } + if len(out.Own) != 1 || out.Own[0] != relay+":verify" { + t.Errorf("the relay it was signed in through: %v, want one verify", out.Own) + } + for _, fn := range []string{"afterOpen", "connectSignedIn", "onJoined"} { + if !strings.Contains(functionBody(t, pageScript(t), fn), "relayTokenCleared()") { + t.Errorf("%s presents the token without checking the relay it was signed in through", fn) + } + } +} + +func TestASignInStartsThroughAClearedRelayAndNamesItsCallback(t *testing.T) { + const relay = "https://brief-relay.onrender.com" + script := pageHarness(t) + ` +const out = {}; +(async () => { + anchor = { state: "listed", relays: ["` + relay + `"] }; + session.relay = "` + relay + `"; + await startSignIn(); + const u = new URL(assigned); + const state = u.searchParams.get("state"); + const page = Buffer.from(state.split(".")[1], "base64url").toString(); + const kept = JSON.parse(sessionStorage.getItem(KEY_SIGN_IN)); + out.listed = { redirect: u.searchParams.get("redirect_uri"), page, kept: kept.relay }; + + // A relay the person confirmed, where the repository's list cannot be + // read, runs the sign-in the same way: GitHub sends the code only to the + // callback the team's app lists. + assigned = ""; requests.length = 0; toasts.length = 0; sessionStorage.m.clear(); + anchor = { state: "unknown", relays: [] }; + session.relay = "https://other-relay.example.org"; + confirmRelay(session.relay); + await startSignIn(); + out.confirmed = { redirect: assigned ? new URL(assigned).searchParams.get("redirect_uri") : "", toasts: toasts.slice() }; + // Unconfirmed, the page asks first and starts nothing. + assigned = ""; sessionStorage.m.clear(); toasts.length = 0; + await startSignIn(); + out.unconfirmed = { redirect: assigned, toasts: toasts.slice() }; + console.log(JSON.stringify(out)); +})(); +` + var out struct { + Listed struct { + Redirect, Page, Kept string + } + Confirmed, Unconfirmed struct { + Redirect string + Toasts []string + } + } + runPage(t, script, &out) + if out.Listed.Redirect != relay+"/auth/callback" || out.Listed.Page != "https://abcdesign.app/acme/brief/brief/" || out.Listed.Kept != relay { + t.Errorf("a listed relay: %+v, want redirect_uri %s/auth/callback and the page in the state", out.Listed, relay) + } + if out.Confirmed.Redirect != "https://other-relay.example.org/auth/callback" { + t.Errorf("a confirmed relay: %+v, want the sign-in to its own callback", out.Confirmed) + } + if out.Unconfirmed.Redirect != "" || len(out.Unconfirmed.Toasts) != 1 || out.Unconfirmed.Toasts[0] != "askTrust:signin" { + t.Errorf("an unconfirmed relay: %+v, want the confirmation asked and no sign-in", out.Unconfirmed) + } +} + +// A confirmation lasts this tab, and lapses after a bounded time: it is kept +// in session storage, never local storage. +func TestAConfirmationLastsTheTabAndLapses(t *testing.T) { + script := pageHarness(t) + ` +const out = {}; +confirmRelay("https://other-relay.example.org"); +out.now = confirmedRelays(); +out.local = localStorage.m.size; +const all = JSON.parse(sessionStorage.getItem(KEY_TRUSTED)); +all["acme/brief"][0].at -= CONFIRM_LIFE + 1; +sessionStorage.setItem(KEY_TRUSTED, JSON.stringify(all)); +out.lapsed = confirmedRelays(); +console.log(JSON.stringify(out)); +` + var out struct { + Now, Lapsed []string + Local int + } + runPage(t, script, &out) + if len(out.Now) != 1 || out.Local != 0 { + t.Errorf("a fresh confirmation: %+v, want it held in session storage alone", out) + } + if len(out.Lapsed) != 0 { + t.Errorf("a confirmation older than CONFIRM_LIFE still counts: %v", out.Lapsed) + } + js := pageScript(t) + if !strings.Contains(functionBody(t, js, "main"), "localStorage.removeItem(LEGACY_TRUSTED)") { + t.Error("main does not drop a confirmation an earlier page kept in local storage") + } +} diff --git a/internal/surface/session/web/vendor/LICENSES.txt b/internal/surface/session/web/vendor/LICENSES.txt new file mode 100644 index 000000000..9c3344ad1 --- /dev/null +++ b/internal/surface/session/web/vendor/LICENSES.txt @@ -0,0 +1,655 @@ +Third-party licences for collab.js. Every package below is bundled into it. + +== @codemirror/autocomplete@6.20.3 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/commands@6.11.1 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/lang-css@6.3.1 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/lang-html@6.4.12 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/lang-javascript@6.2.5 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/lang-markdown@6.5.2 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/language@6.12.4 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/lint@6.9.7 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/search@6.7.2 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/state@6.7.6 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @codemirror/view@6.43.13 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @lezer/common@1.5.3 == +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @lezer/css@1.3.8 == +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @lezer/highlight@1.2.5 == +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @lezer/html@1.3.13 == +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @lezer/javascript@1.5.5 == +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @lezer/lr@1.4.10 == +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @lezer/markdown@1.7.2 == +MIT License + +Copyright (C) 2020 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== @marijn/find-cluster-break@1.0.4 == +MIT License + +Copyright (C) 2024 by Marijn Haverbeke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== codemirror@6.0.2 == +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== crelt@1.0.7 == +Copyright (C) 2020 by Marijn Haverbeke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== isomorphic.js@0.2.5 == +The MIT License (MIT) + +Copyright (c) 2020 Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +== lib0@0.2.119 == +The MIT License (MIT) + +Copyright (c) 2019 Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +== qrcode-generator@2.0.4 == +MIT. The package ships no licence file; its source header reads: + +--------------------------------------------------------------------- +QR Code Generator for JavaScript +Copyright (c) 2009 Kazuhiko Arase +URL: http://www.d-project.com/ +Licensed under the MIT license: +http://www.opensource.org/licenses/mit-license.php +The word 'QR Code' is registered trademark of +DENSO WAVE INCORPORATED +http://www.denso-wave.com/qrcode/faqpatent-e.html + +== style-mod@4.1.4 == +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== w3c-keyname@2.2.8 == +Copyright (C) 2016 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +== y-codemirror.next@0.3.6 == +The MIT License (MIT) + +Copyright (c) 2024 + - Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +== y-protocols@1.0.7 == +The MIT License (MIT) + +Copyright (c) 2019 Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +== yjs@13.6.33 == +The MIT License (MIT) + +Copyright (c) 2023 + - Kevin Jahns . + - Chair of Computer Science 5 (Databases & Information Systems), RWTH Aachen University, Germany + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/surface/session/web/vendor/PROVENANCE.json b/internal/surface/session/web/vendor/PROVENANCE.json new file mode 100644 index 000000000..ca3bc03f7 --- /dev/null +++ b/internal/surface/session/web/vendor/PROVENANCE.json @@ -0,0 +1,218 @@ +{ + "bundle": "collab.js", + "sha256": "13ace93e6ad0d43d08ef4faa8f394af72504c52c682f903acdcc3966ac89fc7a", + "entry": "collab.entry.js", + "entry_sha256": "645e77d6443a4b3511473ae18ca4553ffd732e5a2f023abb440ec4b5a8b6044c", + "bundler": { + "name": "esbuild", + "version": "0.28.2" + }, + "command": "esbuild collab.entry.js --bundle --format=esm --minify --target=es2020 --legal-comments=eof --outfile=collab.js", + "resolved_before": "2026-09-29", + "install": "npm install --ignore-scripts --before=2026-09-29", + "packages": [ + { + "name": "@codemirror/autocomplete", + "version": "6.20.3", + "license": "MIT", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz" + }, + { + "name": "@codemirror/commands", + "version": "6.11.1", + "license": "MIT", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz" + }, + { + "name": "@codemirror/lang-css", + "version": "6.3.1", + "license": "MIT", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz" + }, + { + "name": "@codemirror/lang-html", + "version": "6.4.12", + "license": "MIT", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz" + }, + { + "name": "@codemirror/lang-javascript", + "version": "6.2.5", + "license": "MIT", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz" + }, + { + "name": "@codemirror/lang-markdown", + "version": "6.5.2", + "license": "MIT", + "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz" + }, + { + "name": "@codemirror/language", + "version": "6.12.4", + "license": "MIT", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz" + }, + { + "name": "@codemirror/lint", + "version": "6.9.7", + "license": "MIT", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz" + }, + { + "name": "@codemirror/search", + "version": "6.7.2", + "license": "MIT", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz" + }, + { + "name": "@codemirror/state", + "version": "6.7.6", + "license": "MIT", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz" + }, + { + "name": "@codemirror/view", + "version": "6.43.13", + "license": "MIT", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz" + }, + { + "name": "@lezer/common", + "version": "1.5.3", + "license": "MIT", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz" + }, + { + "name": "@lezer/css", + "version": "1.3.8", + "license": "MIT", + "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz" + }, + { + "name": "@lezer/highlight", + "version": "1.2.5", + "license": "MIT", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz" + }, + { + "name": "@lezer/html", + "version": "1.3.13", + "license": "MIT", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz" + }, + { + "name": "@lezer/javascript", + "version": "1.5.5", + "license": "MIT", + "integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz" + }, + { + "name": "@lezer/lr", + "version": "1.4.10", + "license": "MIT", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz" + }, + { + "name": "@lezer/markdown", + "version": "1.7.2", + "license": "MIT", + "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz" + }, + { + "name": "@marijn/find-cluster-break", + "version": "1.0.4", + "license": "MIT", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz" + }, + { + "name": "codemirror", + "version": "6.0.2", + "license": "MIT", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz" + }, + { + "name": "crelt", + "version": "1.0.7", + "license": "MIT", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz" + }, + { + "name": "isomorphic.js", + "version": "0.2.5", + "license": "MIT", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz" + }, + { + "name": "lib0", + "version": "0.2.119", + "license": "MIT", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz" + }, + { + "name": "qrcode-generator", + "version": "2.0.4", + "license": "MIT", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz" + }, + { + "name": "style-mod", + "version": "4.1.4", + "license": "MIT", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz" + }, + { + "name": "w3c-keyname", + "version": "2.2.8", + "license": "MIT", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz" + }, + { + "name": "y-codemirror.next", + "version": "0.3.6", + "license": "MIT", + "integrity": "sha512-GnmVXhTe+UtoFbbaSdwhq6gdAQZdIY9az0Xj6HR2J4PO6Yw4w3xaaSssU+6T72WhXAI0wf9FbuY2s0Ms+WrexA==", + "resolved": "https://registry.npmjs.org/y-codemirror.next/-/y-codemirror.next-0.3.6.tgz" + }, + { + "name": "y-protocols", + "version": "1.0.7", + "license": "MIT", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz" + }, + { + "name": "yjs", + "version": "13.6.33", + "license": "MIT", + "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz" + } + ] +} diff --git a/internal/surface/session/web/vendor/collab.entry.js b/internal/surface/session/web/vendor/collab.entry.js new file mode 100644 index 000000000..fb3f768cb --- /dev/null +++ b/internal/surface/session/web/vendor/collab.entry.js @@ -0,0 +1,9 @@ +// The browser editor's third-party code, bundled once into collab.js. +export * as Y from "yjs"; +export { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness"; +export { yCollab, yUndoManagerKeymap } from "y-codemirror.next"; +export { EditorView, basicSetup } from "codemirror"; +export { EditorState } from "@codemirror/state"; +export { keymap } from "@codemirror/view"; +export { markdown } from "@codemirror/lang-markdown"; +export { default as qrcode } from "qrcode-generator"; diff --git a/internal/surface/session/web/vendor/collab.js b/internal/surface/session/web/vendor/collab.js new file mode 100644 index 000000000..efa8dd9f4 --- /dev/null +++ b/internal/surface/session/web/vendor/collab.js @@ -0,0 +1,36 @@ +var xO=Object.defineProperty;var xw=(i,e,t)=>e in i?xO(i,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):i[e]=t;var ww=(i,e)=>{for(var t in e)xO(i,t,{get:e[t],enumerable:!0})};var ol=(i,e,t)=>xw(i,typeof e!="symbol"?e+"":e,t);var _r={};ww(_r,{AbsolutePosition:()=>Ul,AbstractConnector:()=>Nh,AbstractStruct:()=>Mr,AbstractType:()=>ge,Array:()=>vn,ContentAny:()=>Di,ContentBinary:()=>Tn,ContentDeleted:()=>Rr,ContentDoc:()=>An,ContentEmbed:()=>mi,ContentFormat:()=>me,ContentJSON:()=>Gs,ContentString:()=>Dt,ContentType:()=>Pt,Doc:()=>_i,GC:()=>Be,ID:()=>Oi,Item:()=>le,Map:()=>$n,PermanentUserData:()=>Gh,RelativePosition:()=>Tr,Skip:()=>Xe,Snapshot:()=>Us,Text:()=>Ar,Transaction:()=>Vl,UndoManager:()=>Bs,UpdateDecoderV1:()=>ot,UpdateDecoderV2:()=>Fe,UpdateEncoderV1:()=>ri,UpdateEncoderV2:()=>Ot,XmlElement:()=>Cn,XmlFragment:()=>Pn,XmlHook:()=>qs,XmlText:()=>Fl,YArrayEvent:()=>Wl,YEvent:()=>Qn,YMapEvent:()=>Nl,YTextEvent:()=>ql,YXmlEvent:()=>Gl,applyUpdate:()=>eQ,applyUpdateV2:()=>Jl,cleanupYTextFormatting:()=>gm,compareIDs:()=>yn,compareRelativePositions:()=>ta,convertUpdateFormatV1ToV2:()=>XQ,convertUpdateFormatV2ToV1:()=>Jg,createAbsolutePositionFromRelativePosition:()=>Er,createDeleteSet:()=>Kl,createDeleteSetFromStructStore:()=>sf,createDocFromSnapshot:()=>bQ,createID:()=>W,createRelativePositionFromJSON:()=>bi,createRelativePositionFromTypeIndex:()=>Xr,createSnapshot:()=>ff,decodeRelativePosition:()=>fQ,decodeSnapshot:()=>OQ,decodeSnapshotV2:()=>Ig,decodeStateVector:()=>af,decodeUpdate:()=>$Q,decodeUpdateV2:()=>Ng,diffUpdate:()=>AQ,diffUpdateV2:()=>uf,emptySnapshot:()=>gQ,encodeRelativePosition:()=>cQ,encodeSnapshot:()=>pQ,encodeSnapshotV2:()=>jg,encodeStateAsUpdate:()=>iQ,encodeStateAsUpdateV2:()=>_g,encodeStateVector:()=>sQ,encodeStateVectorFromUpdate:()=>PQ,encodeStateVectorFromUpdateV2:()=>Gg,equalDeleteSets:()=>Eg,equalSnapshots:()=>dQ,findIndexSS:()=>$t,findRootTypeKey:()=>hf,getItem:()=>Sn,getItemCleanEnd:()=>Kh,getItemCleanStart:()=>Ue,getState:()=>ue,getTypeChildren:()=>LQ,isDeleted:()=>Mn,isParentOf:()=>Is,iterateDeletedStructs:()=>xn,logType:()=>oQ,logUpdate:()=>vQ,logUpdateV2:()=>Wg,mergeDeleteSets:()=>wn,mergeUpdates:()=>qg,mergeUpdatesV2:()=>Ys,obfuscateUpdate:()=>MQ,obfuscateUpdateV2:()=>RQ,parseUpdateMeta:()=>CQ,parseUpdateMetaV2:()=>Fg,readUpdate:()=>Jk,readUpdateV2:()=>lf,relativePositionToJSON:()=>ea,snapshot:()=>mQ,snapshotContainsUpdate:()=>SQ,transact:()=>oe,tryGc:()=>kQ,typeListToArraySnapshot:()=>DQ,typeMapGetAllSnapshot:()=>fm,typeMapGetSnapshot:()=>UQ});var pe=()=>new Map,ll=i=>{let e=pe();return i.forEach((t,n)=>{e.set(n,t)}),e},Ie=(i,e,t)=>{let n=i.get(e);return n===void 0&&i.set(e,n=t()),n},wO=(i,e)=>{let t=[];for(let[n,r]of i)t.push(e(r,n));return t},kO=(i,e)=>{for(let[t,n]of i)if(e(n,t))return!0;return!1};var Ht=()=>new Set;var al=i=>i[i.length-1];var QO=(i,e)=>{for(let t=0;t{for(let t=0;t{for(let t=0;t{let t=new Array(i);for(let n=0;n{this.off(e,n),t(...r)};this.on(e,n)}off(e,t){let n=this._observers.get(e);n!==void 0&&(n.delete(t),n.size===0&&this._observers.delete(e))}emit(e,t){return _t((this._observers.get(e)||pe()).values()).forEach(n=>n(...t))}destroy(){this._observers=pe()}},hl=class{constructor(){this._observers=pe()}on(e,t){Ie(this._observers,e,Ht).add(t)}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}off(e,t){let n=this._observers.get(e);n!==void 0&&(n.delete(t),n.size===0&&this._observers.delete(e))}emit(e,t){return _t((this._observers.get(e)||pe()).values()).forEach(n=>n(...t))}destroy(){this._observers=pe()}};var Re=Math.floor;var un=Math.abs;var or=(i,e)=>ii>e?i:e,uR=Number.isNaN;var fl=i=>i!==0?i<0:1/i<0;var lr=Number.MAX_SAFE_INTEGER,lh=Number.MIN_SAFE_INTEGER,dR=1<<31;var $O=Number.isInteger||(i=>typeof i=="number"&&isFinite(i)&&Re(i)===i),pR=Number.isNaN,OR=Number.parseInt;var ch=String.fromCharCode,kw=String.fromCodePoint,gR=ch(65535),Qw=i=>i.toLowerCase(),vw=/^\s*/g,$w=i=>i.replace(vw,""),Pw=/([A-Z])/g,hh=(i,e)=>$w(i.replace(Pw,t=>`${e}${Qw(t)}`));var Cw=i=>{let e=unescape(encodeURIComponent(i)),t=e.length,n=new Uint8Array(t);for(let r=0;rcr.encode(i),PO=cr?Tw:Cw;var ar=typeof TextDecoder>"u"?null:new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0});ar&&ar.decode(new Uint8Array).length===1&&(ar=null);var ul=(i,e)=>vO(e,()=>i).join("");var pn=class{constructor(){this.cpos=0,this.cbuf=new Uint8Array(100),this.bufs=[]}},di=()=>new pn;var Aw=i=>{let e=i.cpos;for(let t=0;t{let e=new Uint8Array(Aw(i)),t=0;for(let n=0;n{let t=i.cbuf.length;t-i.cpos{let t=i.cbuf.length;i.cpos===t&&(i.bufs.push(i.cbuf),i.cbuf=new Uint8Array(t*2),i.cpos=0),i.cbuf[i.cpos++]=e};var dr=Pe;var B=(i,e)=>{for(;e>127;)Pe(i,128|127&e),e=Re(e/128);Pe(i,127&e)},ks=(i,e)=>{let t=fl(e);for(t&&(e=-e),Pe(i,(e>63?128:0)|(t?64:0)|63&e),e=Re(e/64);e>0;)Pe(i,(e>127?128:0)|127&e),e=Re(e/128)},fh=new Uint8Array(3e4),Rw=fh.length/3,Xw=(i,e)=>{if(e.length{let t=unescape(encodeURIComponent(e)),n=t.length;B(i,n);for(let r=0;rpr(i,qe(e)),pr=(i,e)=>{let t=i.cbuf.length,n=i.cpos,r=or(t-n,e.length),s=e.length-r;i.cbuf.set(e.subarray(0,r),n),i.cpos+=r,s>0&&(i.bufs.push(i.cbuf),i.cbuf=new Uint8Array(Lt(t*2,s)),i.cbuf.set(e.subarray(r)),i.cpos=s)},rt=(i,e)=>{B(i,e.byteLength),pr(i,e)},uh=(i,e)=>{Mw(i,e);let t=new DataView(i.cbuf.buffer,i.cpos,e);return i.cpos+=e,t},Zw=(i,e)=>uh(i,4).setFloat32(0,e,!1),_w=(i,e)=>uh(i,8).setFloat64(0,e,!1),Lw=(i,e)=>uh(i,8).setBigInt64(0,e,!1);var TO=new DataView(new ArrayBuffer(4)),Dw=i=>(TO.setFloat32(0,i),TO.getFloat32(0)===i),fr=(i,e)=>{switch(typeof e){case"string":Pe(i,119),Kt(i,e);break;case"number":$O(e)&&un(e)<=2147483647?(Pe(i,125),ks(i,e)):Dw(e)?(Pe(i,124),Zw(i,e)):(Pe(i,123),_w(i,e));break;case"bigint":Pe(i,122),Lw(i,e);break;case"object":if(e===null)Pe(i,126);else if(ui(e)){Pe(i,117),B(i,e.length);for(let t=0;t0&&B(this,this.count-1),this.count=1,this.w(this,e),this.s=e)}};var AO=i=>{i.count>0&&(ks(i.encoder,i.count===1?i.s:-i.s),i.count>1&&B(i.encoder,i.count-2))},On=class{constructor(){this.encoder=new pn,this.s=0,this.count=0}write(e){this.s===e?this.count++:(AO(this),this.count=1,this.s=e)}toUint8Array(){return AO(this),qe(this.encoder)}};var MO=i=>{if(i.count>0){let e=i.diff*2+(i.count===1?0:1);ks(i.encoder,e),i.count>1&&B(i.encoder,i.count-2)}},ur=class{constructor(){this.encoder=new pn,this.s=0,this.count=0,this.diff=0}write(e){this.diff===e-this.s?(this.s=e,this.count++):(MO(this),this.count=1,this.diff=e-this.s,this.s=e)}toUint8Array(){return MO(this),qe(this.encoder)}},dl=class{constructor(){this.sarr=[],this.s="",this.lensE=new On}write(e){this.s+=e,this.s.length>19&&(this.sarr.push(this.s),this.s=""),this.lensE.write(e.length)}toUint8Array(){let e=new pn;return this.sarr.push(this.s),this.s="",Kt(e,this.sarr.join("")),pr(e,this.lensE.toUint8Array()),qe(e)}};var dt=i=>new Error(i),pt=()=>{throw dt("Method unimplemented")},Ce=()=>{throw dt("Unexpected case")};var Ol=dt("Unexpected end of array"),EO=dt("Integer out of Range"),jw=(i,e,t)=>{if(e<0||t<0||e+t>i.length)throw Ol;return new Uint8Array(i.buffer,i.byteOffset+e,t)},Iw=(i,e,t)=>{if(e<0||t<0||e+t>i.length)throw Ol;return new DataView(i.buffer,i.byteOffset+e,t)},Or=class{constructor(e){this.arr=e,this.pos=0}},Qe=i=>new Or(i),ph=i=>i.pos!==i.arr.length;var Uw=(i,e)=>{let t=jw(i.arr,i.pos,e);return i.pos+=e,t},st=i=>Uw(i,z(i));var gn=i=>i.arr[i.pos++];var z=i=>{let e=0,t=1,n=i.arr.length;for(;i.poslr)throw EO}throw Ol},vs=i=>{let e=i.arr[i.pos++],t=e&63,n=64,r=(e&64)>0?-1:1;if((e&128)===0)return r*t;let s=i.arr.length;for(;i.poslr)throw EO}throw Ol};var Bw=i=>{let e=z(i);if(e===0)return"";{let t=String.fromCodePoint(gn(i));if(--e<100)for(;e--;)t+=String.fromCodePoint(gn(i));else for(;e>0;){let n=e<1e4?e:1e4,r=i.arr.subarray(i.pos,i.pos+n);i.pos+=n,t+=String.fromCodePoint.apply(null,r),e-=n}return decodeURIComponent(escape(t))}},Vw=i=>ar.decode(st(i)),Jt=ar?Vw:Bw;var Oh=(i,e)=>{let t=Iw(i.arr,i.pos,e);return i.pos+=e,t},Yw=i=>Oh(i,4).getFloat32(0,!1),zw=i=>Oh(i,8).getFloat64(0,!1),Ww=i=>Oh(i,8).getBigInt64(0,!1);var Nw=[i=>{},i=>null,vs,Yw,zw,Ww,i=>!1,i=>!0,Jt,i=>{let e=z(i),t={};for(let n=0;n{let e=z(i),t=[];for(let n=0;nNw[127-gn(i)](i),Qs=class extends Or{constructor(e,t){super(e),this.reader=t,this.s=null,this.count=0}read(){return this.count===0&&(this.s=this.reader(this),ph(this)?this.count=z(this)+1:this.count=-1),this.count--,this.s}};var mn=class extends Or{constructor(e){super(e),this.s=0,this.count=0}read(){if(this.count===0){this.s=vs(this);let e=fl(this.s);this.count=1,e&&(this.s=-this.s,this.count=z(this)+2)}return this.count--,this.s}};var mr=class extends Or{constructor(e){super(e),this.s=0,this.count=0,this.diff=0}read(){if(this.count===0){let e=vs(this),t=e&1;this.diff=Re(e/2),this.count=1,t&&(this.count=z(this)+2)}return this.s+=this.diff,this.count--,this.s}},pl=class{constructor(e){this.decoder=new mn(e),this.str=Jt(this.decoder),this.spos=0}read(){let e=this.spos+this.decoder.read(),t=this.str.slice(this.spos,e);return this.spos=e,t}};var yR=crypto.subtle,_O=crypto.getRandomValues.bind(crypto);var gh=()=>_O(new Uint32Array(1))[0];var qw="10000000-1000-4000-8000"+-1e11,LO=()=>qw.replace(/[018]/g,i=>(i^gh()&15>>i/4).toString(16));var pi=Date.now;var bh=i=>new Promise(i);var wR=Promise.all.bind(Promise);var yh=i=>i===void 0?null:i;var Sh=class{constructor(){this.map=new Map}setItem(e,t){this.map.set(e,t)}getItem(e){return this.map.get(e)}},DO=new Sh,Kw=!0;try{typeof localStorage<"u"&&localStorage&&(DO=localStorage,Kw=!1)}catch{}var jO=DO;var bn=Symbol("Equality"),gl=(i,e)=>i===e||!!i?.[bn]?.(e)||!1;var IO=i=>typeof i=="object",UO=Object.assign,ek=Object.keys;var BO=(i,e)=>{for(let t in i)e(i[t],t)};var $s=i=>ek(i).length;var VO=i=>{for(let e in i)return!1;return!0},br=(i,e)=>{for(let t in i)if(!e(i[t],t))return!1;return!0},Ps=(i,e)=>Object.prototype.hasOwnProperty.call(i,e),wh=(i,e)=>i===e||$s(i)===$s(e)&&br(i,(t,n)=>(t!==void 0||Ps(e,n))&&gl(e[n],t)),tk=Object.freeze,kh=i=>{for(let e in i){let t=i[e];(typeof t=="object"||typeof t=="function")&&kh(i[e])}return tk(i)};var Cs=(i,e,t=0)=>{try{for(;ti;var Mi=(i,e)=>{if(i===e)return!0;if(i==null||e==null||i.constructor!==e.constructor&&(i.constructor||Object)!==(e.constructor||Object))return!1;if(i[bn]!=null)return i[bn](e);switch(i.constructor){case ArrayBuffer:i=new Uint8Array(i),e=new Uint8Array(e);case Uint8Array:{if(i.byteLength!==e.byteLength)return!1;for(let t=0;te.includes(i);var Ts=typeof process<"u"&&process.release&&/node|io\.js/.test(process.release.name)&&Object.prototype.toString.call(typeof process<"u"?process:0)==="[object process]";var kR=typeof navigator<"u"?/Mac/.test(navigator.platform):!1,ei,ik=[],nk=()=>{if(ei===void 0)if(Ts){ei=pe();let i=process.argv,e=null;for(let t=0;t{if(i.length!==0){let[e,t]=i.split("=");ei.set(`--${hh(e,"-")}`,t),ei.set(`-${hh(e,"-")}`,t)}})):ei=pe();return ei},$h=i=>nk().has(i);var As=i=>Ts?yh(process.env[i.toUpperCase().replaceAll("-","_")]):yh(jO.getItem(i));var zO=i=>$h("--"+i)||As(i)!==null,WO=zO("production"),rk=Ts&&YO(process.env.FORCE_COLOR,["true","1","2"]),NO=rk||!$h("--no-colors")&&!zO("no-color")&&(!Ts||process.stdout.isTTY)&&(!Ts||$h("--color")||As("COLORTERM")!==null||(As("TERM")||"").includes("color"));var sk=i=>new Uint8Array(i);var qO=i=>{let e=sk(i.byteLength);return e.set(i),e};var Ch=class{constructor(e,t){this.left=e,this.right=t}},Ge=(i,e)=>new Ch(i,e);var GO=(i,e)=>i.forEach(t=>e(t.left,t.right));var Ah=i=>i.next()>=.5,bl=(i,e,t)=>Re(i.next()*(t+1-e)+e);var Mh=(i,e,t)=>Re(i.next()*(t+1-e)+e);var Rh=(i,e,t)=>Mh(i,e,t);var lk=i=>ch(Rh(i,97,122)),FO=(i,e=0,t=20)=>{let n=Rh(i,e,t),r="";for(let s=0;se[Rh(i,0,e.length-1)];var ck=Symbol("0schema"),Xh=class{constructor(){this._rerrs=[]}extend(e,t,n,r=null){this._rerrs.push({path:e,expected:t,has:n,message:r})}toString(){let e=[];for(let t=this._rerrs.length-1;t>0;t--){let n=this._rerrs[t];e.push(ul(" ",(this._rerrs.length-t)*2)+`${n.path!=null?`[${n.path}] `:""}${n.has} doesn't match ${n.expected}. ${n.message}`)}return e.join(` +`)}},Eh=(i,e)=>i===e?!0:i==null||e==null||i.constructor!==e.constructor?!1:i[bn]?gl(i,e):ui(i)?cl(i,t=>ys(e,n=>Eh(t,n))):IO(i)?br(i,(t,n)=>Eh(t,e[n])):!1,Te=class{extends(e){let[t,n]=[this.shape,e.shape];return this.constructor._dilutes&&([n,t]=[t,n]),Eh(t,n)}equals(e){return this.constructor===e.constructor&&Mi(this.shape,e.shape)}[ck](){return!0}[bn](e){return this.equals(e)}validate(e){return this.check(e)}check(e,t){pt()}get nullable(){return Qr(this,Rl)}get optional(){return new Sl(this)}cast(e){return HO(e,this),e}expect(e){return HO(e,this),e}};ol(Te,"_dilutes",!1);var Ms=class extends Te{constructor(e,t){super(),this.shape=e,this._c=t}check(e,t=void 0){let n=e?.constructor===this.shape&&(this._c==null||this._c(e));return!n&&t?.extend(null,this.shape.name,e?.constructor.name,e?.constructor!==this.shape?"Constructor match failed":"Check failed"),n}},Se=(i,e=null)=>new Ms(i,e),vR=Se(Ms),Rs=class extends Te{constructor(e){super(),this.shape=e}check(e,t){let n=this.shape(e);return!n&&t?.extend(null,"custom prop",e?.constructor.name,"failed to check custom prop"),n}},ve=i=>new Rs(i),$R=Se(Rs),wr=class extends Te{constructor(e){super(),this.shape=e}check(e,t){let n=this.shape.some(r=>r===e);return!n&&t?.extend(null,this.shape.join(" | "),e.toString()),n}},Al=(...i)=>new wr(i),KO=Se(wr),hk=RegExp.escape||(i=>i.replace(/[().|&,$^[\]]/g,e=>"\\"+e)),JO=i=>{if(kr.check(i))return[hk(i)];if(KO.check(i))return i.shape.map(e=>e+"");if(rg.check(i))return["[+-]?\\d+.?\\d*"];if(sg.check(i))return[".*"];if(Pl.check(i))return i.shape.map(JO).flat(1);Ce()},Zh=class extends Te{constructor(e){super(),this.shape=e,this._r=new RegExp("^"+e.map(JO).map(t=>`(${t.join("|")})`).join("")+"$")}check(e,t){let n=this._r.exec(e)!=null;return!n&&t?.extend(null,this._r.toString(),e.toString(),"String doesn't match string template."),n}};var PR=Se(Zh),fk=Symbol("optional"),Sl=class extends Te{constructor(e){super(),this.shape=e}check(e,t){let n=e===void 0||this.shape.check(e);return!n&&t?.extend(null,"undefined (optional)","()"),n}get[fk](){return!0}},uk=Se(Sl),xl=class extends Te{check(e,t){return t?.extend(null,"never",typeof e),!1}},CR=new xl,TR=Se(xl),Tl=class Tl extends Te{constructor(e,t=!1){super(),this.shape=e,this._isPartial=t}get partial(){return new Tl(this.shape,!0)}check(e,t){return e==null?(t?.extend(null,"object","null"),!1):br(this.shape,(n,r)=>{let s=this._isPartial&&!Ps(e,r)||n.check(e[r],t);return!s&&t?.extend(r.toString(),n.toString(),typeof e[r],"Object property does not match"),s})}};ol(Tl,"_dilutes",!0);var wl=Tl,dk=i=>new wl(i),pk=Se(wl),Ok=ve(i=>i!=null&&(i.constructor===Object||i.constructor==null)),kl=class extends Te{constructor(e,t){super(),this.shape={keys:e,values:t}}check(e,t){return e!=null&&br(e,(n,r)=>{let s=this.shape.keys.check(r,t);return!s&&t?.extend(r+"","Record",typeof e,s?"Key doesn't match schema":"Value doesn't match value"),s&&this.shape.values.check(n,t)})}},eg=(i,e)=>new kl(i,e),gk=Se(kl),Ql=class extends Te{constructor(e){super(),this.shape=e}check(e,t){return e!=null&&br(this.shape,(n,r)=>{let s=n.check(e[r],t);return!s&&t?.extend(r.toString(),"Tuple",typeof n),s})}},mk=(...i)=>new Ql(i),AR=Se(Ql),vl=class extends Te{constructor(e){super(),this.shape=e.length===1?e[0]:new Sr(e)}check(e,t){let n=ui(e)&&cl(e,r=>this.shape.check(r));return!n&&t?.extend(null,"Array",""),n}},tg=(...i)=>new vl(i),bk=Se(vl),yk=ve(i=>ui(i)),$l=class extends Te{constructor(e,t){super(),this.shape=e,this._c=t}check(e,t){let n=e instanceof this.shape&&(this._c==null||this._c(e));return!n&&t?.extend(null,this.shape.name,e?.constructor.name),n}},Sk=(i,e=null)=>new $l(i,e),MR=Se($l),xk=Sk(Te),_h=class extends Te{constructor(e){super(),this.len=e.length-1,this.args=mk(...e.slice(-1)),this.res=e[this.len]}check(e,t){let n=e.constructor===Function&&e.length<=this.len;return!n&&t?.extend(null,"function",typeof e),n}};var wk=Se(_h),kk=ve(i=>typeof i=="function"),Lh=class extends Te{constructor(e){super(),this.shape=e}check(e,t){let n=cl(this.shape,r=>r.check(e,t));return!n&&t?.extend(null,"Intersectinon",typeof e),n}};var RR=Se(Lh,i=>i.shape.length>0),Sr=class extends Te{constructor(e){super(),this.shape=e}check(e,t){let n=ys(this.shape,r=>r.check(e,t));return t?.extend(null,"Union",typeof e),n}};ol(Sr,"_dilutes",!0);var Qr=(...i)=>i.findIndex(e=>Pl.check(e))>=0?Qr(...i.map(e=>Xs(e)).map(e=>Pl.check(e)?e.shape:[e]).flat(1)):i.length===1?i[0]:new Sr(i),Pl=Se(Sr),ig=()=>!0,Cl=ve(ig),Qk=Se(Rs,i=>i.shape===ig),jh=ve(i=>typeof i=="bigint"),vk=ve(i=>i===jh),ng=ve(i=>typeof i=="symbol"),XR=ve(i=>i===ng),xr=ve(i=>typeof i=="number"),rg=ve(i=>i===xr),kr=ve(i=>typeof i=="string"),sg=ve(i=>i===kr),Ml=ve(i=>typeof i=="boolean"),$k=ve(i=>i===Ml),og=Al(void 0),ER=Se(wr,i=>i.shape.length===1&&i.shape[0]===void 0),ZR=Al(void 0);var Rl=Al(null),Pk=Se(wr,i=>i.shape.length===1&&i.shape[0]===null),_R=Se(Uint8Array),LR=Se(Ms,i=>i.shape===Uint8Array),Ck=Qr(xr,kr,Rl,og,jh,Ml,ng),DR=(()=>{let i=tg(Cl),e=eg(kr,Cl),t=Qr(xr,kr,Rl,Ml,i,e);return i.shape=t,e.shape.values=t,t})(),Xs=i=>{if(xk.check(i))return i;if(Ok.check(i)){let e={};for(let t in i)e[t]=Xs(i[t]);return dk(e)}else{if(yk.check(i))return Qr(...i.map(Xs));if(Ck.check(i))return Al(i);if(kk.check(i))return Se(i)}Ce()},HO=WO?()=>{}:(i,e)=>{let t=new Xh;if(!e.check(i,t))throw dt(`Expected value to be of type ${e.constructor.name}. +${t.toString()}`)},Dh=class{constructor(e){this.patterns=[],this.$state=e}if(e,t){return this.patterns.push({if:Xs(e),h:t}),this}else(e){return this.if(Cl,e)}done(){return(e,t)=>{for(let n=0;nnew Dh(i),lg=Tk(Cl).if(rg,(i,e)=>bl(e,lh,lr)).if(sg,(i,e)=>FO(e)).if($k,(i,e)=>Ah(e)).if(vk,(i,e)=>BigInt(bl(e,lh,lr))).if(Pl,(i,e)=>yr(e,yl(e,i.shape))).if(pk,(i,e)=>{let t={};for(let n in i.shape){let r=i.shape[n];if(uk.check(r)){if(Ah(e))continue;r=r.shape}t[n]=lg(r,e)}return t}).if(bk,(i,e)=>{let t=[],n=Mh(e,0,42);for(let r=0;ryl(e,i.shape)).if(Pk,(i,e)=>null).if(wk,(i,e)=>{let t=yr(e,i.res);return()=>t}).if(Qk,(i,e)=>yr(e,yl(e,[xr,kr,Rl,og,jh,Ml,tg(xr),eg(Qr("a","b","c"),xr)]))).if(gk,(i,e)=>{let t={},n=bl(e,0,3);for(let r=0;rlg(Xs(e),i);var ti=typeof document<"u"?document:{},Mk=i=>ti.createElement(i),Rk=()=>ti.createDocumentFragment(),IR=ve(i=>i.nodeType===jk),Xk=i=>ti.createTextNode(i),UR=typeof DOMParser<"u"?new DOMParser:null;var Ek=(i,e)=>(GO(e,(t,n)=>{n===!1?i.removeAttribute(t):n===!0?i.setAttribute(t,""):i.setAttribute(t,n)}),i);var Zk=i=>{let e=Rk();for(let t=0;t(hg(i,Zk(e)),i);var Es=(i,e=[],t=[])=>ag(Ek(Mk(i),e),t),BR=ve(i=>i.nodeType===_k);var vr=Xk,VR=ve(i=>i.nodeType===Lk);var cg=i=>wO(i,(e,t)=>`${t}:${e};`).join("");var hg=(i,e)=>i.appendChild(e),_k=ti.ELEMENT_NODE,Lk=ti.TEXT_NODE,YR=ti.CDATA_SECTION_NODE,zR=ti.COMMENT_NODE,Dk=ti.DOCUMENT_NODE,WR=ti.DOCUMENT_TYPE_NODE,jk=ti.DOCUMENT_FRAGMENT_NODE,NR=ve(i=>i.nodeType===Dk);var ii=Symbol;var Zs=ii(),_s=ii(),Ih=ii(),Uh=ii(),Bh=ii(),Ls=ii(),Vh=ii(),$r=ii(),Yh=ii(),ug=i=>{i.length===1&&i[0]?.constructor===Function&&(i=i[0]());let e=[],t=[],n=0;for(;n0&&t.push(e.join(""));n{i.length===1&&i[0]?.constructor===Function&&(i=i[0]());let e=[],t=[],n=pe(),r=[],s=0;for(;s0||a.length>0?(e.push("%c"+o),t.push(a)):e.push(o)}else break}}for(s>0&&(r=t,r.unshift(e.join("")));s{console.log(...dg(i)),pg.forEach(e=>e.print(i))},zh=(...i)=>{console.warn(...dg(i)),i.unshift($r),pg.forEach(e=>e.print(i))};var pg=Ht();var Og=i=>({[Symbol.iterator](){return this},next:i}),gg=(i,e)=>Og(()=>{let t;do t=i.next();while(!t.done&&!e(t.value));return t}),El=(i,e)=>Og(()=>{let{done:t,value:n}=i.next();return{done:t,value:t?void 0:e(n)}});var Nh=class extends sr{constructor(e,t){super(),this.doc=e,this.awareness=t}},Pr=class{constructor(e,t){this.clock=e,this.len=t}},Zi=class{constructor(){this.clients=new Map}},xn=(i,e,t)=>e.clients.forEach((n,r)=>{let s=i.doc.store.clients.get(r);if(s!=null){let o=s[s.length-1],l=o.id.clock+o.length;for(let a=0,c=n[a];a{let t=0,n=i.length-1;for(;t<=n;){let r=Re((t+n)/2),s=i[r],o=s.clock;if(o<=e){if(e{let t=i.clients.get(e.client);return t!==void 0&&qk(t,e.clock)!==null},rf=i=>{i.clients.forEach(e=>{e.sort((r,s)=>r.clock-s.clock);let t,n;for(t=1,n=1;t=s.clock?e[n-1]=new Pr(r.clock,Lt(r.len,s.clock+s.len-r.clock)):(n{let e=new Zi;for(let t=0;t{if(!e.clients.has(r)){let s=n.slice();for(let o=t+1;o{Ie(i.clients,e,()=>[]).push(new Pr(t,n))},Kl=()=>new Zi,sf=i=>{let e=Kl();return i.clients.forEach((t,n)=>{let r=[];for(let s=0;s0&&e.clients.set(n,r)}),e},ni=(i,e)=>{B(i.restEncoder,e.clients.size),_t(e.clients.entries()).sort((t,n)=>n[0]-t[0]).forEach(([t,n])=>{i.resetDsCurVal(),B(i.restEncoder,t);let r=n.length;B(i.restEncoder,r);for(let s=0;s{let e=new Zi,t=z(i.restDecoder);for(let n=0;n0){let o=Ie(e.clients,r,()=>[]);for(let l=0;l{let n=new Zi,r=z(i.restDecoder);for(let s=0;s0){let s=new Ot;return B(s.restEncoder,0),ni(s,n),s.toUint8Array()}return null},Eg=(i,e)=>{if(i.clients.size!==e.clients.size)return!1;for(let[t,n]of i.clients.entries()){let r=e.clients.get(t);if(r===void 0||n.length!==r.length)return!1;for(let s=0;s!0,meta:s=null,autoLoad:o=!1,shouldLoad:l=!0}={}){super(),this.gc=n,this.gcFilter=r,this.clientID=Zg(),this.guid=e,this.collectionid=t,this.share=new Map,this.store=new Bl,this._transaction=null,this._transactionCleanups=[],this.subdocs=new Set,this._item=null,this.shouldLoad=l,this.autoLoad=o,this.meta=s,this.isLoaded=!1,this.isSynced=!1,this.isDestroyed=!1,this.whenLoaded=bh(c=>{this.on("load",()=>{this.isLoaded=!0,c(this)})});let a=()=>bh(c=>{let h=f=>{(f===void 0||f===!0)&&(this.off("sync",h),c())};this.on("sync",h)});this.on("sync",c=>{c===!1&&this.isSynced&&(this.whenSynced=a()),this.isSynced=c===void 0||c===!0,this.isSynced&&!this.isLoaded&&this.emit("load",[this])}),this.whenSynced=a()}load(){let e=this._item;e!==null&&!this.shouldLoad&&oe(e.parent.doc,t=>{t.subdocsLoaded.add(this)},null,!0),this.shouldLoad=!0}getSubdocs(){return this.subdocs}getSubdocGuids(){return new Set(_t(this.subdocs).map(e=>e.guid))}transact(e,t=null){return oe(this,e,t)}get(e,t=ge){let n=Ie(this.share,e,()=>{let s=new t;return s._integrate(this,null),s}),r=n.constructor;if(t!==ge&&r!==t)if(r===ge){let s=new t;s._map=n._map,n._map.forEach(o=>{for(;o!==null;o=o.left)o.parent=s}),s._start=n._start;for(let o=s._start;o!==null;o=o.right)o.parent=s;return s._length=n._length,this.share.set(e,s),s._integrate(this,null),s}else throw new Error(`Type with the name ${e} has already been defined with a different constructor`);return n}getArray(e=""){return this.get(e,vn)}getText(e=""){return this.get(e,Ar)}getMap(e=""){return this.get(e,$n)}getXmlElement(e=""){return this.get(e,Cn)}getXmlFragment(e=""){return this.get(e,Pn)}toJSON(){let e={};return this.share.forEach((t,n)=>{e[n]=t.toJSON()}),e}destroy(){this.isDestroyed=!0,_t(this.subdocs).forEach(t=>t.destroy());let e=this._item;if(e!==null){this._item=null;let t=e.content;t.doc=new i({guid:this.guid,...t.opts,shouldLoad:!1}),t.doc._item=e,oe(e.parent.doc,n=>{let r=t.doc;e.deleted||n.subdocsAdded.add(r),n.subdocsRemoved.add(this)},null,!0)}this.emit("destroyed",[!0]),this.emit("destroy",[this]),super.destroy()}},kn=class{constructor(e){this.restDecoder=e}resetDsCurVal(){}readDsClock(){return z(this.restDecoder)}readDsLen(){return z(this.restDecoder)}},ot=class extends kn{readLeftID(){return W(z(this.restDecoder),z(this.restDecoder))}readRightID(){return W(z(this.restDecoder),z(this.restDecoder))}readClient(){return z(this.restDecoder)}readInfo(){return gn(this.restDecoder)}readString(){return Jt(this.restDecoder)}readParentInfo(){return z(this.restDecoder)===1}readTypeRef(){return z(this.restDecoder)}readLen(){return z(this.restDecoder)}readAny(){return gr(this.restDecoder)}readBuf(){return qO(st(this.restDecoder))}readJSON(){return JSON.parse(Jt(this.restDecoder))}readKey(){return Jt(this.restDecoder)}},Il=class{constructor(e){this.dsCurrVal=0,this.restDecoder=e}resetDsCurVal(){this.dsCurrVal=0}readDsClock(){return this.dsCurrVal+=z(this.restDecoder),this.dsCurrVal}readDsLen(){let e=z(this.restDecoder)+1;return this.dsCurrVal+=e,e}},Fe=class extends Il{constructor(e){super(e),this.keys=[],z(e),this.keyClockDecoder=new mr(st(e)),this.clientDecoder=new mn(st(e)),this.leftClockDecoder=new mr(st(e)),this.rightClockDecoder=new mr(st(e)),this.infoDecoder=new Qs(st(e),gn),this.stringDecoder=new pl(st(e)),this.parentInfoDecoder=new Qs(st(e),gn),this.typeRefDecoder=new mn(st(e)),this.lenDecoder=new mn(st(e))}readLeftID(){return new Oi(this.clientDecoder.read(),this.leftClockDecoder.read())}readRightID(){return new Oi(this.clientDecoder.read(),this.rightClockDecoder.read())}readClient(){return this.clientDecoder.read()}readInfo(){return this.infoDecoder.read()}readString(){return this.stringDecoder.read()}readParentInfo(){return this.parentInfoDecoder.read()===1}readTypeRef(){return this.typeRefDecoder.read()}readLen(){return this.lenDecoder.read()}readAny(){return gr(this.restDecoder)}readBuf(){return st(this.restDecoder)}readJSON(){return gr(this.restDecoder)}readKey(){let e=this.keyClockDecoder.read();if(e{n=Lt(n,e[0].id.clock);let r=$t(e,n);B(i.restEncoder,e.length-r),i.writeClient(t),B(i.restEncoder,n);let s=e[r];s.write(i,n-s.id.clock);for(let o=r+1;o{let n=new Map;t.forEach((r,s)=>{ue(e,s)>r&&n.set(s,r)}),Fs(e).forEach((r,s)=>{t.has(s)||n.set(s,0)}),B(i.restEncoder,n.size),_t(n.entries()).sort((r,s)=>s[0]-r[0]).forEach(([r,s])=>{Gk(i,e.clients.get(r),r,s)})},Fk=(i,e)=>{let t=pe(),n=z(i.restDecoder);for(let r=0;r{let n=[],r=_t(t.keys()).sort((d,p)=>d-p);if(r.length===0)return null;let s=()=>{if(r.length===0)return null;let d=t.get(r[r.length-1]);for(;d.refs.length===d.i;)if(r.pop(),r.length>0)d=t.get(r[r.length-1]);else return null;return d},o=s();if(o===null)return null;let l=new Bl,a=new Map,c=(d,p)=>{let O=a.get(d);(O==null||O>p)&&a.set(d,p)},h=o.refs[o.i++],f=new Map,u=()=>{for(let d of n){let p=d.id.client,O=t.get(p);O?(O.i--,l.clients.set(p,O.refs.slice(O.i)),t.delete(p),O.i=0,O.refs=[]):l.clients.set(p,[d]),r=r.filter(g=>g!==p)}n.length=0};for(;;){if(h.constructor!==Xe){let p=Ie(f,h.id.client,()=>ue(e,h.id.client))-h.id.clock;if(p<0)n.push(h),c(h.id.client,h.id.clock-1),u();else{let O=h.getMissing(i,e);if(O!==null){n.push(h);let g=t.get(O)||{refs:[],i:0};if(g.refs.length===g.i)c(O,ue(e,O)),u();else{h=g.refs[g.i++];continue}}else(p===0||p0)h=n.pop();else if(o!==null&&o.i0){let d=new Ot;return of(d,l,new Map),B(d.restEncoder,0),{missing:a,update:d.toUint8Array()}}return null},Kk=(i,e)=>of(i,e.doc.store,e.beforeState),lf=(i,e,t,n=new Fe(i))=>oe(e,r=>{r.local=!1;let s=!1,o=r.doc,l=o.store,a=Fk(n,o),c=Hk(r,l,a),h=l.pendingStructs;if(h){for(let[u,d]of h.missing)if(dd)&&h.missing.set(u,d)}h.update=Ys([h.update,c.update])}}else l.pendingStructs=c;let f=yg(n,r,l);if(l.pendingDs){let u=new Fe(Qe(l.pendingDs));z(u.restDecoder);let d=yg(u,r,l);f&&d?l.pendingDs=Ys([f,d]):l.pendingDs=f||d}else l.pendingDs=f;if(s){let u=l.pendingStructs.update;l.pendingStructs=null,Jl(r.doc,u)}},t,!1),Jk=(i,e,t)=>lf(i,e,t,new ot(i)),Jl=(i,e,t,n=Fe)=>{let r=Qe(e);lf(r,i,t,new n(r))},eQ=(i,e,t)=>Jl(i,e,t,ot),tQ=(i,e,t=new Map)=>{of(i,e.store,t),ni(i,sf(e.store))},_g=(i,e=new Uint8Array([0]),t=new Ot)=>{let n=af(e);tQ(t,i,n);let r=[t.toUint8Array()];if(i.store.pendingDs&&r.push(i.store.pendingDs),i.store.pendingStructs&&r.push(uf(i.store.pendingStructs.update,e)),r.length>1){if(t.constructor===ri)return qg(r.map((s,o)=>o===0?s:Jg(s)));if(t.constructor===Ot)return Ys(r)}return r[0]},iQ=(i,e)=>_g(i,e,new ri),Lg=i=>{let e=new Map,t=z(i.restDecoder);for(let n=0;nLg(new kn(Qe(i))),cf=(i,e)=>(B(i.restEncoder,e.size),_t(e.entries()).sort((t,n)=>n[0]-t[0]).forEach(([t,n])=>{B(i.restEncoder,t),B(i.restEncoder,n)}),i),nQ=(i,e)=>cf(i,Fs(e.store)),rQ=(i,e=new Cr)=>(i instanceof Map?cf(e,i):nQ(e,i),e.toUint8Array()),sQ=i=>rQ(i,new Li),qh=class{constructor(){this.l=[]}},Sg=()=>new qh,xg=(i,e)=>i.l.push(e),wg=(i,e)=>{let t=i.l,n=t.length;i.l=t.filter(r=>e!==r),n===i.l.length&&console.error("[yjs] Tried to remove event handler that doesn't exist.")},Dg=(i,e,t)=>Cs(i.l,[e,t]),Oi=class{constructor(e,t){this.client=e,this.clock=t}},yn=(i,e)=>i===e||i!==null&&e!==null&&i.client===e.client&&i.clock===e.clock,W=(i,e)=>new Oi(i,e),kg=(i,e)=>{B(i,e.client),B(i,e.clock)},Qg=i=>W(z(i),z(i)),hf=i=>{for(let[e,t]of i.doc.share.entries())if(t===i)return e;throw Ce()},Is=(i,e)=>{for(;e!==null;){if(e.parent===i)return!0;e=e.parent._item}return!1},oQ=i=>{let e=[],t=i._start;for(;t;)e.push(t),t=t.right;console.log("Children: ",e),console.log("Children content: ",e.filter(n=>!n.deleted).map(n=>n.content))},Gh=class{constructor(e,t=e.getMap("users")){let n=new Map;this.yusers=t,this.doc=e,this.clients=new Map,this.dss=n;let r=(s,o)=>{let l=s.get("ds"),a=s.get("ids"),c=h=>this.clients.set(h,o);l.observe(h=>{h.changes.added.forEach(f=>{f.content.getContent().forEach(u=>{u instanceof Uint8Array&&this.dss.set(o,wn([this.dss.get(o)||Kl(),gi(new kn(Qe(u)))]))})})}),this.dss.set(o,wn(l.map(h=>gi(new kn(Qe(h)))))),a.observe(h=>h.changes.added.forEach(f=>f.content.getContent().forEach(c))),a.forEach(c)};t.observe(s=>{s.keysChanged.forEach(o=>r(t.get(o),o))}),t.forEach(r)}setUserMapping(e,t,n,{filter:r=()=>!0}={}){let s=this.yusers,o=s.get(n);o||(o=new $n,o.set("ids",new vn),o.set("ds",new vn),s.set(n,o)),o.get("ids").push([t]),s.observe(l=>{setTimeout(()=>{let a=s.get(n);if(a!==o){o=a,this.clients.forEach((f,u)=>{n===f&&o.get("ids").push([u])});let c=new Li,h=this.dss.get(n);h&&(ni(c,h),o.get("ds").push([c.toUint8Array()]))}},0)}),e.on("afterTransaction",l=>{setTimeout(()=>{let a=o.get("ds"),c=l.deleteSet;if(l.local&&c.clients.size>0&&r(l,c)){let h=new Li;ni(h,c),a.push([h.toUint8Array()])}})})}getUserByClientId(e){return this.clients.get(e)||null}getUserByDeletedId(e){for(let[t,n]of this.dss.entries())if(Mn(n,e))return t;return null}},Tr=class{constructor(e,t,n,r=0){this.type=e,this.tname=t,this.item=n,this.assoc=r}},ea=i=>{let e={};return i.type&&(e.type=i.type),i.tname&&(e.tname=i.tname),i.item&&(e.item=i.item),i.assoc!=null&&(e.assoc=i.assoc),e},bi=i=>new Tr(i.type==null?null:W(i.type.client,i.type.clock),i.tname??null,i.item==null?null:W(i.item.client,i.item.clock),i.assoc==null?0:i.assoc),Ul=class{constructor(e,t,n=0){this.type=e,this.index=t,this.assoc=n}},lQ=(i,e,t=0)=>new Ul(i,e,t),Zl=(i,e,t)=>{let n=null,r=null;return i._item===null?r=hf(i):n=W(i._item.id.client,i._item.id.clock),new Tr(n,r,e,t)},Xr=(i,e,t=0)=>{let n=i._start;if(t<0){if(e===0)return Zl(i,null,t);e--}for(;n!==null;){if(!n.deleted&&n.countable){if(n.length>e)return Zl(i,W(n.id.client,n.id.clock+e),t);e-=n.length}if(n.right===null&&t<0)return Zl(i,n.lastId,t);n=n.right}return Zl(i,null,t)},aQ=(i,e)=>{let{type:t,tname:n,item:r,assoc:s}=e;if(r!==null)B(i,0),kg(i,r);else if(n!==null)dr(i,1),Kt(i,n);else if(t!==null)dr(i,2),kg(i,t);else throw Ce();return ks(i,s),i},cQ=i=>{let e=di();return aQ(e,i),qe(e)},hQ=i=>{let e=null,t=null,n=null;switch(z(i)){case 0:n=Qg(i);break;case 1:t=Jt(i);break;case 2:e=Qg(i)}let r=ph(i)?vs(i):0;return new Tr(e,t,n,r)},fQ=i=>hQ(Qe(i)),uQ=(i,e)=>{let t=Sn(i,e),n=e.clock-t.id.clock;return{item:t,diff:n}},Er=(i,e,t=!0)=>{let n=e.store,r=i.item,s=i.type,o=i.tname,l=i.assoc,a=null,c=0;if(r!==null){if(ue(n,r.client)<=r.clock)return null;let h=t?tf(n,r):uQ(n,r),f=h.item;if(!(f instanceof le))return null;if(a=f.parent,a._item===null||!a._item.deleted){c=f.deleted||!f.countable?0:h.diff+(l>=0?0:1);let u=f.left;for(;u!==null;)!u.deleted&&u.countable&&(c+=u.length),u=u.left}}else{if(o!==null)a=e.get(o);else if(s!==null){if(ue(n,s.client)<=s.clock)return null;let{item:h}=t?tf(n,s):{item:Sn(n,s)};if(h instanceof le&&h.content instanceof Pt)a=h.content.type;else return null}else throw Ce();l>=0?c=a._length:c=0}return lQ(a,c,i.assoc)},ta=(i,e)=>i===e||i!==null&&e!==null&&i.tname===e.tname&&yn(i.item,e.item)&&yn(i.type,e.type)&&i.assoc===e.assoc,Us=class{constructor(e,t){this.ds=e,this.sv=t}},dQ=(i,e)=>{let t=i.ds.clients,n=e.ds.clients,r=i.sv,s=e.sv;if(r.size!==s.size||t.size!==n.size)return!1;for(let[o,l]of r.entries())if(s.get(o)!==l)return!1;for(let[o,l]of t.entries()){let a=n.get(o)||[];if(l.length!==a.length)return!1;for(let c=0;c(ni(e,i.ds),cf(e,i.sv),e.toUint8Array()),pQ=i=>jg(i,new Li),Ig=(i,e=new Il(Qe(i)))=>new Us(gi(e),Lg(e)),OQ=i=>Ig(i,new kn(Qe(i))),ff=(i,e)=>new Us(i,e),gQ=ff(Kl(),new Map),mQ=i=>ff(sf(i.store),Fs(i.store)),Ri=(i,e)=>e===void 0?!i.deleted:e.sv.has(i.id.client)&&(e.sv.get(i.id.client)||0)>i.id.clock&&!Mn(e.ds,i.id),Fh=(i,e)=>{let t=Ie(i.meta,Fh,Ht),n=i.doc.store;t.has(e)||(e.sv.forEach((r,s)=>{r{}),t.add(e))},bQ=(i,e,t=new _i)=>{if(i.gc)throw new Error("Garbage-collection must be disabled in `originDoc`!");let{sv:n,ds:r}=e,s=new Ot;return i.transact(o=>{let l=0;n.forEach(a=>{a>0&&l++}),B(s.restEncoder,l);for(let[a,c]of n){if(c===0)continue;c{let n=new t(Qe(e)),r=new si(n,!1);for(let o=r.curr;o!==null;o=r.next())if((i.sv.get(o.id.client)||0)yQ(i,e,ot),Bl=class{constructor(){this.clients=new Map,this.pendingStructs=null,this.pendingDs=null}},Fs=i=>{let e=new Map;return i.clients.forEach((t,n)=>{let r=t[t.length-1];e.set(n,r.id.clock+r.length)}),e},ue=(i,e)=>{let t=i.clients.get(e);if(t===void 0)return 0;let n=t[t.length-1];return n.id.clock+n.length},Ug=(i,e)=>{let t=i.clients.get(e.id.client);if(t===void 0)t=[],i.clients.set(e.id.client,t);else{let n=t[t.length-1];if(n.id.clock+n.length!==e.id.clock)throw Ce()}t.push(e)},$t=(i,e)=>{let t=0,n=i.length-1,r=i[n],s=r.id.clock;if(s===e)return n;let o=Re(e/(s+r.length-1)*n);for(;t<=n;){if(r=i[o],s=r.id.clock,s<=e){if(e{let t=i.clients.get(e.client);return t[$t(t,e.clock)]},Sn=xQ,Hh=(i,e,t)=>{let n=$t(e,t),r=e[n];return r.id.clock{let t=i.doc.store.clients.get(e.client);return t[Hh(i,t,e.clock)]},Kh=(i,e,t)=>{let n=e.clients.get(t.client),r=$t(n,t.clock),s=n[r];return t.clock!==s.id.clock+s.length-1&&s.constructor!==Be&&n.splice(r+1,0,Hl(i,s,t.clock-s.id.clock+1)),s},wQ=(i,e,t)=>{let n=i.clients.get(e.id.client);n[$t(n,e.id.clock)]=t},Bg=(i,e,t,n,r)=>{if(n===0)return;let s=t+n,o=Hh(i,e,t),l;do l=e[o++],se.deleteSet.clients.size===0&&!kO(e.afterState,(t,n)=>e.beforeState.get(n)!==t)?!1:(rf(e.deleteSet),Kk(i,e),ni(i,e.deleteSet),!0),$g=(i,e,t)=>{let n=e._item;(n===null||n.id.clock<(i.beforeState.get(n.id.client)||0)&&!n.deleted)&&Ie(i.changed,e,Ht).add(t)},Dl=(i,e)=>{let t=i[e],n=i[e-1],r=e;for(;r>0;t=n,n=i[--r-1]){if(n.deleted===t.deleted&&n.constructor===t.constructor&&n.mergeWith(t)){t instanceof le&&t.parentSub!==null&&t.parent._map.get(t.parentSub)===t&&t.parent._map.set(t.parentSub,n);continue}break}let s=e-r;return s&&i.splice(e+1-s,s),s},Vg=(i,e,t)=>{for(let[n,r]of i.clients.entries()){let s=e.clients.get(n);for(let o=r.length-1;o>=0;o--){let l=r[o],a=l.clock+l.len;for(let c=$t(s,l.clock),h=s[c];c{i.clients.forEach((t,n)=>{let r=e.clients.get(n);for(let s=t.length-1;s>=0;s--){let o=t[s],l=or(r.length-1,1+$t(r,o.clock+o.len-1));for(let a=l,c=r[a];a>0&&c.id.clock>=o.clock;c=r[a])a-=1+Dl(r,a)}})},kQ=(i,e,t)=>{Vg(i,e,t),Yg(i,e)},zg=(i,e)=>{if(el.push(()=>{(c._item===null||!c._item.deleted)&&c._callObserver(t,a)})),l.push(()=>{t.changedParentTypes.forEach((a,c)=>{c._dEH.l.length>0&&(c._item===null||!c._item.deleted)&&l.push(()=>{a=a.filter(h=>h.target._item===null||!h.target._item.deleted),a.forEach(h=>{h.currentTarget=c,h._path=null}),a.sort((h,f)=>h.path.length-f.path.length),Dg(c._dEH,a,t)})}),l.push(()=>n.emit("afterTransaction",[t,n])),l.push(()=>{t._needFormattingCleanup&&zQ(t)})}),Cs(l,[])}finally{n.gc&&Vg(s,r,n.gcFilter),Yg(s,r),t.afterState.forEach((h,f)=>{let u=t.beforeState.get(f)||0;if(u!==h){let d=r.clients.get(f),p=Lt($t(d,u),1);for(let O=d.length-1;O>=p;)O-=1+Dl(d,O)}});for(let h=o.length-1;h>=0;h--){let{client:f,clock:u}=o[h].id,d=r.clients.get(f),p=$t(d,u);p+11||p>0&&Dl(d,p)}if(!t.local&&t.afterState.get(n.clientID)!==t.beforeState.get(n.clientID)&&(Xl($r,Zs,"[yjs] ",_s,Ls,"Changed the client-id because another client seems to be using it."),n.clientID=Zg()),n.emit("afterTransactionCleanup",[t,n]),n._observers.has("update")){let h=new ri;vg(h,t)&&n.emit("update",[h.toUint8Array(),t.origin,n,t])}if(n._observers.has("updateV2")){let h=new Ot;vg(h,t)&&n.emit("updateV2",[h.toUint8Array(),t.origin,n,t])}let{subdocsAdded:l,subdocsLoaded:a,subdocsRemoved:c}=t;(l.size>0||c.size>0||a.size>0)&&(l.forEach(h=>{h.clientID=n.clientID,h.collectionid==null&&(h.collectionid=n.collectionid),n.subdocs.add(h)}),c.forEach(h=>n.subdocs.delete(h)),n.emit("subdocs",[{loaded:a,added:l,removed:c},n,t]),c.forEach(h=>h.destroy())),i.length<=e+1?(n._transactionCleanups=[],n.emit("afterAllTransactions",[n,i])):zg(i,e+1)}}},oe=(i,e,t=null,n=!0)=>{let r=i._transactionCleanups,s=!1,o=null;i._transaction===null&&(s=!0,i._transaction=new Vl(i,t,n),r.push(i._transaction),r.length===1&&i.emit("beforeAllTransactions",[i]),i.emit("beforeTransaction",[i._transaction,i]));try{o=e(i._transaction)}finally{if(s){let l=i._transaction===r[0];i._transaction=null,l&&zg(r,0)}}return o},Jh=class{constructor(e,t){this.insertions=t,this.deletions=e,this.meta=new Map}},Pg=(i,e,t)=>{xn(i,t.deletions,n=>{n instanceof le&&e.scope.some(r=>r===i.doc||Is(r,n))&&mf(n,!1)})},Cg=(i,e,t)=>{let n=null,r=i.doc,s=i.scope;oe(r,l=>{for(;e.length>0&&i.currStackItem===null;){let a=r.store,c=e.pop(),h=new Set,f=[],u=!1;xn(l,c.insertions,d=>{if(d instanceof le){if(d.redone!==null){let{item:p,diff:O}=tf(a,d.id);O>0&&(p=Ue(l,W(p.id.client,p.id.clock+O))),d=p}!d.deleted&&s.some(p=>p===l.doc||Is(p,d))&&f.push(d)}}),xn(l,c.deletions,d=>{d instanceof le&&s.some(p=>p===l.doc||Is(p,d))&&!Mn(c.insertions,d.id)&&h.add(d)}),h.forEach(d=>{u=bm(l,d,h,c.insertions,i.ignoreRemoteMapChanges,i)!==null||u});for(let d=f.length-1;d>=0;d--){let p=f[d];i.deleteFilter(p)&&(p.delete(l),u=!0)}i.currStackItem=u?c:null}l.changed.forEach((a,c)=>{a.has(null)&&c._searchMarker&&(c._searchMarker.length=0)}),n=l},i);let o=i.currStackItem;if(o!=null){let l=n.changedParentTypes;i.emit("stack-item-popped",[{stackItem:o,type:t,changedParentTypes:l,origin:i},i]),i.currStackItem=null}return o},Bs=class extends sr{constructor(e,{captureTimeout:t=500,captureTransaction:n=a=>!0,deleteFilter:r=()=>!0,trackedOrigins:s=new Set([null]),ignoreRemoteMapChanges:o=!1,doc:l=ui(e)?e[0].doc:e instanceof _i?e:e.doc}={}){super(),this.scope=[],this.doc=l,this.addToScope(e),this.deleteFilter=r,s.add(this),this.trackedOrigins=s,this.captureTransaction=n,this.undoStack=[],this.redoStack=[],this.undoing=!1,this.redoing=!1,this.currStackItem=null,this.lastChange=0,this.ignoreRemoteMapChanges=o,this.captureTimeout=t,this.afterTransactionHandler=a=>{if(!this.captureTransaction(a)||!this.scope.some(g=>a.changedParentTypes.has(g)||g===this.doc)||!this.trackedOrigins.has(a.origin)&&(!a.origin||!this.trackedOrigins.has(a.origin.constructor)))return;let c=this.undoing,h=this.redoing,f=c?this.redoStack:this.undoStack;c?this.stopCapturing():h||this.clear(!1,!0);let u=new Zi;a.afterState.forEach((g,b)=>{let w=a.beforeState.get(b)||0,x=g-w;x>0&&js(u,b,w,x)});let d=pi(),p=!1;if(this.lastChange>0&&d-this.lastChange0&&!c&&!h){let g=f[f.length-1];g.deletions=wn([g.deletions,a.deleteSet]),g.insertions=wn([g.insertions,u])}else f.push(new Jh(a.deleteSet,u)),p=!0;!c&&!h&&(this.lastChange=d),xn(a,a.deleteSet,g=>{g instanceof le&&this.scope.some(b=>b===a.doc||Is(b,g))&&mf(g,!0)});let O=[{stackItem:f[f.length-1],origin:a.origin,type:c?"redo":"undo",changedParentTypes:a.changedParentTypes},this];p?this.emit("stack-item-added",O):this.emit("stack-item-updated",O)},this.destroy=this.destroy.bind(this),this.doc.on("afterTransaction",this.afterTransactionHandler),this.doc.on("destroy",this.destroy)}addToScope(e){let t=new Set(this.scope);e=ui(e)?e:[e],e.forEach(n=>{t.has(n)||(t.add(n),(n instanceof ge?n.doc!==this.doc:n!==this.doc)&&zh("[yjs#509] Not same Y.Doc"),this.scope.push(n))})}addTrackedOrigin(e){this.trackedOrigins.add(e)}removeTrackedOrigin(e){this.trackedOrigins.delete(e)}clear(e=!0,t=!0){(e&&this.canUndo()||t&&this.canRedo())&&this.doc.transact(n=>{e&&(this.undoStack.forEach(r=>Pg(n,this,r)),this.undoStack=[]),t&&(this.redoStack.forEach(r=>Pg(n,this,r)),this.redoStack=[]),this.emit("stack-cleared",[{undoStackCleared:e,redoStackCleared:t}])})}stopCapturing(){this.lastChange=0}undo(){this.undoing=!0;let e;try{e=Cg(this,this.undoStack,"undo")}finally{this.undoing=!1}return e}redo(){this.redoing=!0;let e;try{e=Cg(this,this.redoStack,"redo")}finally{this.redoing=!1}return e}canUndo(){return this.undoStack.length>0}canRedo(){return this.redoStack.length>0}destroy(){this.trackedOrigins.delete(this),this.doc.off("afterTransaction",this.afterTransactionHandler),this.doc.off("destroy",this.destroy),super.destroy()}};function*QQ(i){let e=z(i.restDecoder);for(let t=0;tWg(i,ot),Wg=(i,e=Fe)=>{let t=[],n=new e(Qe(i)),r=new si(n,!1);for(let o=r.curr;o!==null;o=r.next())t.push(o);Xl("Structs: ",t);let s=gi(n);Xl("DeleteSet: ",s)},$Q=i=>Ng(i,ot),Ng=(i,e=Fe)=>{let t=[],n=new e(Qe(i)),r=new si(n,!1);for(let s=r.curr;s!==null;s=r.next())t.push(s);return{structs:t,ds:gi(n)}},Vs=class{constructor(e){this.currClient=0,this.startClock=0,this.written=0,this.encoder=e,this.clientStructs=[]}},qg=i=>Ys(i,ot,ri),Gg=(i,e=Cr,t=Fe)=>{let n=new e,r=new si(new t(Qe(i)),!1),s=r.curr;if(s!==null){let o=0,l=s.id.client,a=s.id.clock!==0,c=a?0:s.id.clock+s.length;for(;s!==null;s=r.next())l!==s.id.client&&(c!==0&&(o++,B(n.restEncoder,l),B(n.restEncoder,c)),l=s.id.client,c=0,a=s.id.clock!==0),s.constructor===Xe&&(a=!0),a||(c=s.id.clock+s.length);c!==0&&(o++,B(n.restEncoder,l),B(n.restEncoder,c));let h=di();return B(h,o),RO(h,n.restEncoder),n.restEncoder=h,n.toUint8Array()}else return B(n.restEncoder,0),n.toUint8Array()},PQ=i=>Gg(i,Li,ot),Fg=(i,e=Fe)=>{let t=new Map,n=new Map,r=new si(new e(Qe(i)),!1),s=r.curr;if(s!==null){let o=s.id.client,l=s.id.clock;for(t.set(o,l);s!==null;s=r.next())o!==s.id.client&&(n.set(o,l),t.set(s.id.client,s.id.clock),o=s.id.client),l=s.id.clock+s.length;n.set(o,l)}return{from:t,to:n}},CQ=i=>Fg(i,ot),TQ=(i,e)=>{if(i.constructor===Be){let{client:t,clock:n}=i.id;return new Be(W(t,n+e),i.length-e)}else if(i.constructor===Xe){let{client:t,clock:n}=i.id;return new Xe(W(t,n+e),i.length-e)}else{let t=i,{client:n,clock:r}=t.id;return new le(W(n,r+e),null,W(n,r+e-1),null,t.rightOrigin,t.parent,t.parentSub,t.content.splice(e))}},Ys=(i,e=Fe,t=Ot)=>{if(i.length===1)return i[0];let n=i.map(h=>new e(Qe(h))),r=n.map(h=>new si(h,!0)),s=null,o=new t,l=new Vs(o);for(;r=r.filter(u=>u.curr!==null),r.sort((u,d)=>{if(u.curr.id.client===d.curr.id.client){let p=u.curr.id.clock-d.curr.id.clock;return p===0?u.curr.constructor===d.curr.constructor?0:u.curr.constructor===Xe?1:-1:p}else return d.curr.id.client-u.curr.id.client}),r.length!==0;){let h=r[0],f=h.curr.id.client;if(s!==null){let u=h.curr,d=!1;for(;u!==null&&u.id.clock+u.length<=s.struct.id.clock+s.struct.length&&u.id.client>=s.struct.id.client;)u=h.next(),d=!0;if(u===null||u.id.client!==f||d&&u.id.clock>s.struct.id.clock+s.struct.length)continue;if(f!==s.struct.id.client)Xi(l,s.struct,s.offset),s={struct:u,offset:0},h.next();else if(s.struct.id.clock+s.struct.length0&&(s.struct.constructor===Xe?s.struct.length-=p:u=TQ(u,p)),s.struct.mergeWith(u)||(Xi(l,s.struct,s.offset),s={struct:u,offset:0},h.next())}}else s={struct:h.curr,offset:0},h.next();for(let u=h.curr;u!==null&&u.id.client===f&&u.id.clock===s.struct.id.clock+s.struct.length&&u.constructor!==Xe;u=h.next())Xi(l,s.struct,s.offset),s={struct:u,offset:0}}s!==null&&(Xi(l,s.struct,s.offset),s=null),df(l);let a=n.map(h=>gi(h)),c=wn(a);return ni(o,c),o.toUint8Array()},uf=(i,e,t=Fe,n=Ot)=>{let r=af(e),s=new n,o=new Vs(s),l=new t(Qe(i)),a=new si(l,!1);for(;a.curr;){let h=a.curr,f=h.id.client,u=r.get(f)||0;if(a.curr.constructor===Xe){a.next();continue}if(h.id.clock+h.length>u)for(Xi(o,h,Lt(u-h.id.clock,0)),a.next();a.curr&&a.curr.id.client===f;)Xi(o,a.curr,0),a.next();else for(;a.curr&&a.curr.id.client===f&&a.curr.id.clock+a.curr.length<=u;)a.next()}df(o);let c=gi(l);return ni(s,c),s.toUint8Array()},AQ=(i,e)=>uf(i,e,ot,ri),Hg=i=>{i.written>0&&(i.clientStructs.push({written:i.written,restEncoder:qe(i.encoder.restEncoder)}),i.encoder.restEncoder=di(),i.written=0)},Xi=(i,e,t)=>{i.written>0&&i.currClient!==e.id.client&&Hg(i),i.written===0&&(i.currClient=e.id.client,i.encoder.writeClient(e.id.client),B(i.encoder.restEncoder,e.id.clock+t)),e.write(i.encoder,t),i.written++},df=i=>{Hg(i);let e=i.encoder.restEncoder;B(e,i.clientStructs.length);for(let t=0;t{let r=new t(Qe(i)),s=new si(r,!1),o=new n,l=new Vs(o);for(let c=s.curr;c!==null;c=s.next())Xi(l,e(c),0);df(l);let a=gi(r);return ni(o,a),o.toUint8Array()},Kg=({formatting:i=!0,subdocs:e=!0,yxml:t=!0}={})=>{let n=0,r=pe(),s=pe(),o=pe(),l=pe();return l.set(null,null),a=>{switch(a.constructor){case Be:case Xe:return a;case le:{let c=a,h=c.content;switch(h.constructor){case Rr:break;case Pt:{if(t){let f=h.type;f instanceof Cn&&(f.nodeName=Ie(s,f.nodeName,()=>"node-"+n)),f instanceof qs&&(f.hookName=Ie(s,f.hookName,()=>"hook-"+n))}break}case Di:{let f=h;f.arr=f.arr.map(()=>n);break}case Tn:{let f=h;f.content=new Uint8Array([n]);break}case An:{let f=h;e&&(f.opts={},f.doc.guid=n+"");break}case mi:{let f=h;f.embed={};break}case me:{let f=h;i&&(f.key=Ie(o,f.key,()=>n+""),f.value=Ie(l,f.value,()=>({i:n})));break}case Gs:{let f=h;f.arr=f.arr.map(()=>n);break}case Dt:{let f=h;f.str=ul(n%10+"",f.str.length);break}default:Ce()}return c.parentSub&&(c.parentSub=Ie(r,c.parentSub,()=>n+"")),n++,a}default:Ce()}}},MQ=(i,e)=>ia(i,Kg(e),ot,ri),RQ=(i,e)=>ia(i,Kg(e),Fe,Ot),XQ=i=>ia(i,vh,ot,Ot),Jg=i=>ia(i,vh,Fe,ri),Tg="You must not compute changes after the event-handler fired.",Qn=class{constructor(e,t){this.target=e,this.currentTarget=e,this.transaction=t,this._changes=null,this._keys=null,this._delta=null,this._path=null}get path(){return this._path||(this._path=EQ(this.currentTarget,this.target))}deletes(e){return Mn(this.transaction.deleteSet,e.id)}get keys(){if(this._keys===null){if(this.transaction.doc._transactionCleanups.length===0)throw dt(Tg);let e=new Map,t=this.target;this.transaction.changed.get(t).forEach(r=>{if(r!==null){let s=t._map.get(r),o,l;if(this.adds(s)){let a=s.left;for(;a!==null&&this.adds(a);)a=a.left;if(this.deletes(s))if(a!==null&&this.deletes(a))o="delete",l=al(a.content.getContent());else return;else a!==null&&this.deletes(a)?(o="update",l=al(a.content.getContent())):(o="add",l=void 0)}else if(this.deletes(s))o="delete",l=al(s.content.getContent());else return;e.set(r,{action:o,oldValue:l})}}),this._keys=e}return this._keys}get delta(){return this.changes.delta}adds(e){return e.id.clock>=(this.transaction.beforeState.get(e.id.client)||0)}get changes(){let e=this._changes;if(e===null){if(this.transaction.doc._transactionCleanups.length===0)throw dt(Tg);let t=this.target,n=Ht(),r=Ht(),s=[];if(e={added:n,deleted:r,delta:s,keys:this.keys},this.transaction.changed.get(t).has(null)){let l=null,a=()=>{l&&s.push(l)};for(let c=t._start;c!==null;c=c.right)c.deleted?this.deletes(c)&&!this.adds(c)&&((l===null||l.delete===void 0)&&(a(),l={delete:0}),l.delete+=c.length,r.add(c)):this.adds(c)?((l===null||l.insert===void 0)&&(a(),l={insert:[]}),l.insert=l.insert.concat(c.content.getContent()),n.add(c)):((l===null||l.retain===void 0)&&(a(),l={retain:0}),l.retain+=c.length);l!==null&&l.retain===void 0&&a()}this._changes=e}return e}},EQ=(i,e)=>{let t=[];for(;e._item!==null&&e!==i;){if(e._item.parentSub!==null)t.unshift(e._item.parentSub);else{let n=0,r=e._item.parent._start;for(;r!==e._item&&r!==null;)!r.deleted&&r.countable&&(n+=r.length),r=r.right;t.unshift(n)}e=e._item.parent}return t},Ee=()=>{zh("Invalid access: Add Yjs type to a document before reading data.")},em=80,pf=0,ef=class{constructor(e,t){e.marker=!0,this.p=e,this.index=t,this.timestamp=pf++}},ZQ=i=>{i.timestamp=pf++},tm=(i,e,t)=>{i.p.marker=!1,i.p=e,e.marker=!0,i.index=t,i.timestamp=pf++},_Q=(i,e,t)=>{if(i.length>=em){let n=i.reduce((r,s)=>r.timestamp{if(i._start===null||e===0||i._searchMarker===null)return null;let t=i._searchMarker.length===0?null:i._searchMarker.reduce((s,o)=>un(e-s.index)e;)n=n.left,!n.deleted&&n.countable&&(r-=n.length);for(;n.left!==null&&n.left.id.client===n.id.client&&n.left.id.clock+n.left.length===n.id.clock;)n=n.left,!n.deleted&&n.countable&&(r-=n.length);return t!==null&&un(t.index-r){for(let n=i.length-1;n>=0;n--){let r=i[n];if(t>0){let s=r.p;for(s.marker=!1;s&&(s.deleted||!s.countable);)s=s.left,s&&!s.deleted&&s.countable&&(r.index-=s.length);if(s===null||s.marker===!0){i.splice(n,1);continue}r.p=s,s.marker=!0}(e0&&e===r.index)&&(r.index=Lt(e,r.index+t))}},LQ=i=>{i.doc??Ee();let e=i._start,t=[];for(;e;)t.push(e),e=e.right;return t},ra=(i,e,t)=>{let n=i,r=e.changedParentTypes;for(;Ie(r,i,()=>[]).push(t),i._item!==null;)i=i._item.parent;Dg(n._eH,t,e)},ge=class{constructor(){this._item=null,this._map=new Map,this._start=null,this.doc=null,this._length=0,this._eH=Sg(),this._dEH=Sg(),this._searchMarker=null}get parent(){return this._item?this._item.parent:null}_integrate(e,t){this.doc=e,this._item=t}_copy(){throw pt()}clone(){throw pt()}_write(e){}get _first(){let e=this._start;for(;e!==null&&e.deleted;)e=e.right;return e}_callObserver(e,t){!e.local&&this._searchMarker&&(this._searchMarker.length=0)}observe(e){xg(this._eH,e)}observeDeep(e){xg(this._dEH,e)}unobserve(e){wg(this._eH,e)}unobserveDeep(e){wg(this._dEH,e)}toJSON(){}},im=(i,e,t)=>{i.doc??Ee(),e<0&&(e=i._length+e),t<0&&(t=i._length+t);let n=t-e,r=[],s=i._start;for(;s!==null&&n>0;){if(s.countable&&!s.deleted){let o=s.content.getContent();if(o.length<=e)e-=o.length;else{for(let l=e;l0;l++)r.push(o[l]),n--;e=0}}s=s.right}return r},nm=i=>{i.doc??Ee();let e=[],t=i._start;for(;t!==null;){if(t.countable&&!t.deleted){let n=t.content.getContent();for(let r=0;r{let t=[],n=i._start;for(;n!==null;){if(n.countable&&Ri(n,e)){let r=n.content.getContent();for(let s=0;s{let t=0,n=i._start;for(i.doc??Ee();n!==null;){if(n.countable&&!n.deleted){let r=n.content.getContent();for(let s=0;s{let t=[];return Ws(i,(n,r)=>{t.push(e(n,r,i))}),t},jQ=i=>{let e=i._start,t=null,n=0;return{[Symbol.iterator](){return this},next:()=>{if(t===null){for(;e!==null&&e.deleted;)e=e.right;if(e===null)return{done:!0,value:void 0};t=e.content.getContent(),n=0,e=e.right}let r=t[n++];return t.length<=n&&(t=null),{done:!1,value:r}}}},sm=(i,e)=>{i.doc??Ee();let t=na(i,e),n=i._start;for(t!==null&&(n=t.p,e-=t.index);n!==null;n=n.right)if(!n.deleted&&n.countable){if(e{let r=t,s=i.doc,o=s.clientID,l=s.store,a=t===null?e._start:t.right,c=[],h=()=>{c.length>0&&(r=new le(W(o,ue(l,o)),r,r&&r.lastId,a,a&&a.id,e,null,new Di(c)),r.integrate(i,0),c=[])};n.forEach(f=>{if(f===null)c.push(f);else switch(f.constructor){case Number:case Object:case Boolean:case Array:case String:c.push(f);break;default:switch(h(),f.constructor){case Uint8Array:case ArrayBuffer:r=new le(W(o,ue(l,o)),r,r&&r.lastId,a,a&&a.id,e,null,new Tn(new Uint8Array(f))),r.integrate(i,0);break;case _i:r=new le(W(o,ue(l,o)),r,r&&r.lastId,a,a&&a.id,e,null,new An(f)),r.integrate(i,0);break;default:if(f instanceof ge)r=new le(W(o,ue(l,o)),r,r&&r.lastId,a,a&&a.id,e,null,new Pt(f)),r.integrate(i,0);else throw new Error("Unexpected content type in insert operation")}}}),h()},om=()=>dt("Length exceeded!"),lm=(i,e,t,n)=>{if(t>e._length)throw om();if(t===0)return e._searchMarker&&zs(e._searchMarker,t,n.length),Yl(i,e,null,n);let r=t,s=na(e,t),o=e._start;for(s!==null&&(o=s.p,t-=s.index,t===0&&(o=o.prev,t+=o&&o.countable&&!o.deleted?o.length:0));o!==null;o=o.right)if(!o.deleted&&o.countable){if(t<=o.length){t{let r=(e._searchMarker||[]).reduce((s,o)=>o.index>s.index?o:s,{index:0,p:e._start}).p;if(r)for(;r.right;)r=r.right;return Yl(i,e,r,t)},am=(i,e,t,n)=>{if(n===0)return;let r=t,s=n,o=na(e,t),l=e._start;for(o!==null&&(l=o.p,t-=o.index);l!==null&&t>0;l=l.right)!l.deleted&&l.countable&&(t0&&l!==null;)l.deleted||(n0)throw om();e._searchMarker&&zs(e._searchMarker,r,-s+n)},zl=(i,e,t)=>{let n=e._map.get(t);n!==void 0&&n.delete(i)},Of=(i,e,t,n)=>{let r=e._map.get(t)||null,s=i.doc,o=s.clientID,l;if(n==null)l=new Di([n]);else switch(n.constructor){case Number:case Object:case Boolean:case Array:case String:case Date:case BigInt:l=new Di([n]);break;case Uint8Array:l=new Tn(n);break;case _i:l=new An(n);break;default:if(n instanceof ge)l=new Pt(n);else throw new Error("Unexpected content type")}new le(W(o,ue(s.store,o)),r,r&&r.lastId,null,null,e,t,l).integrate(i,0)},gf=(i,e)=>{i.doc??Ee();let t=i._map.get(e);return t!==void 0&&!t.deleted?t.content.getContent()[t.length-1]:void 0},cm=i=>{let e={};return i.doc??Ee(),i._map.forEach((t,n)=>{t.deleted||(e[n]=t.content.getContent()[t.length-1])}),e},hm=(i,e)=>{i.doc??Ee();let t=i._map.get(e);return t!==void 0&&!t.deleted},UQ=(i,e,t)=>{let n=i._map.get(e)||null;for(;n!==null&&(!t.sv.has(n.id.client)||n.id.clock>=(t.sv.get(n.id.client)||0));)n=n.left;return n!==null&&Ri(n,t)?n.content.getContent()[n.length-1]:void 0},fm=(i,e)=>{let t={};return i._map.forEach((n,r)=>{let s=n;for(;s!==null&&(!e.sv.has(s.id.client)||s.id.clock>=(e.sv.get(s.id.client)||0));)s=s.left;s!==null&&Ri(s,e)&&(t[r]=s.content.getContent()[s.length-1])}),t},_l=i=>(i.doc??Ee(),gg(i._map.entries(),e=>!e[1].deleted)),Wl=class extends Qn{},vn=class i extends ge{constructor(){super(),this._prelimContent=[],this._searchMarker=[]}static from(e){let t=new i;return t.push(e),t}_integrate(e,t){super._integrate(e,t),this.insert(0,this._prelimContent),this._prelimContent=null}_copy(){return new i}clone(){let e=new i;return e.insert(0,this.toArray().map(t=>t instanceof ge?t.clone():t)),e}get length(){return this.doc??Ee(),this._length}_callObserver(e,t){super._callObserver(e,t),ra(this,e,new Wl(this,e))}insert(e,t){this.doc!==null?oe(this.doc,n=>{lm(n,this,e,t)}):this._prelimContent.splice(e,0,...t)}push(e){this.doc!==null?oe(this.doc,t=>{IQ(t,this,e)}):this._prelimContent.push(...e)}unshift(e){this.insert(0,e)}delete(e,t=1){this.doc!==null?oe(this.doc,n=>{am(n,this,e,t)}):this._prelimContent.splice(e,t)}get(e){return sm(this,e)}toArray(){return nm(this)}slice(e=0,t=this.length){return im(this,e,t)}toJSON(){return this.map(e=>e instanceof ge?e.toJSON():e)}map(e){return rm(this,e)}forEach(e){Ws(this,e)}[Symbol.iterator](){return jQ(this)}_write(e){e.writeTypeRef(av)}},BQ=i=>new vn,Nl=class extends Qn{constructor(e,t,n){super(e,t),this.keysChanged=n}},$n=class i extends ge{constructor(e){super(),this._prelimContent=null,e===void 0?this._prelimContent=new Map:this._prelimContent=new Map(e)}_integrate(e,t){super._integrate(e,t),this._prelimContent.forEach((n,r)=>{this.set(r,n)}),this._prelimContent=null}_copy(){return new i}clone(){let e=new i;return this.forEach((t,n)=>{e.set(n,t instanceof ge?t.clone():t)}),e}_callObserver(e,t){ra(this,e,new Nl(this,e,t))}toJSON(){this.doc??Ee();let e={};return this._map.forEach((t,n)=>{if(!t.deleted){let r=t.content.getContent()[t.length-1];e[n]=r instanceof ge?r.toJSON():r}}),e}get size(){return[..._l(this)].length}keys(){return El(_l(this),e=>e[0])}values(){return El(_l(this),e=>e[1].content.getContent()[e[1].length-1])}entries(){return El(_l(this),e=>[e[0],e[1].content.getContent()[e[1].length-1]])}forEach(e){this.doc??Ee(),this._map.forEach((t,n)=>{t.deleted||e(t.content.getContent()[t.length-1],n,this)})}[Symbol.iterator](){return this.entries()}delete(e){this.doc!==null?oe(this.doc,t=>{zl(t,this,e)}):this._prelimContent.delete(e)}set(e,t){return this.doc!==null?oe(this.doc,n=>{Of(n,this,e,t)}):this._prelimContent.set(e,t),t}get(e){return gf(this,e)}has(e){return hm(this,e)}clear(){this.doc!==null?oe(this.doc,e=>{this.forEach(function(t,n,r){zl(e,r,n)})}):this._prelimContent.clear()}_write(e){e.writeTypeRef(cv)}},VQ=i=>new $n,Ei=(i,e)=>i===e||typeof i=="object"&&typeof e=="object"&&i&&e&&wh(i,e),Ns=class{constructor(e,t,n,r){this.left=e,this.right=t,this.index=n,this.currentAttributes=r}forward(){this.right===null&&Ce(),this.right.content.constructor===me?this.right.deleted||Zr(this.currentAttributes,this.right.content):this.right.deleted||(this.index+=this.right.length),this.left=this.right,this.right=this.right.right}},Ag=(i,e,t)=>{for(;e.right!==null&&t>0;)e.right.content.constructor===me?e.right.deleted||Zr(e.currentAttributes,e.right.content):e.right.deleted||(t{let r=new Map,s=n?na(e,t):null;if(s){let o=new Ns(s.p.left,s.p,s.index,r);return Ag(i,o,t-s.index)}else{let o=new Ns(null,e._start,0,r);return Ag(i,o,t)}},um=(i,e,t,n)=>{for(;t.right!==null&&(t.right.deleted===!0||t.right.content.constructor===me&&Ei(n.get(t.right.content.key),t.right.content.value));)t.right.deleted||n.delete(t.right.content.key),t.forward();let r=i.doc,s=r.clientID;n.forEach((o,l)=>{let a=t.left,c=t.right,h=new le(W(s,ue(r.store,s)),a,a&&a.lastId,c,c&&c.id,e,null,new me(l,o));h.integrate(i,0),t.right=h,t.forward()})},Zr=(i,e)=>{let{key:t,value:n}=e;n===null?i.delete(t):i.set(t,n)},dm=(i,e)=>{for(;i.right!==null;){if(!(i.right.deleted||i.right.content.constructor===me&&Ei(e[i.right.content.key]??null,i.right.content.value)))break;i.forward()}},pm=(i,e,t,n)=>{let r=i.doc,s=r.clientID,o=new Map;for(let l in n){let a=n[l],c=t.currentAttributes.get(l)??null;if(!Ei(c,a)){o.set(l,c);let{left:h,right:f}=t;t.right=new le(W(s,ue(r.store,s)),h,h&&h.lastId,f,f&&f.id,e,null,new me(l,a)),t.right.integrate(i,0),t.forward()}}return o},Wh=(i,e,t,n,r)=>{t.currentAttributes.forEach((u,d)=>{r[d]===void 0&&(r[d]=null)});let s=i.doc,o=s.clientID;dm(t,r);let l=pm(i,e,t,r),a=n.constructor===String?new Dt(n):n instanceof ge?new Pt(n):new mi(n),{left:c,right:h,index:f}=t;e._searchMarker&&zs(e._searchMarker,t.index,a.getLength()),h=new le(W(o,ue(s.store,o)),c,c&&c.lastId,h,h&&h.id,e,null,a),h.integrate(i,0),t.right=h,t.index=f,t.forward(),um(i,e,t,l)},Mg=(i,e,t,n,r)=>{let s=i.doc,o=s.clientID;dm(t,r);let l=pm(i,e,t,r);e:for(;t.right!==null&&(n>0||l.size>0&&(t.right.deleted||t.right.content.constructor===me));){if(!t.right.deleted)switch(t.right.content.constructor){case me:{let{key:a,value:c}=t.right.content,h=r[a];if(h!==void 0){if(Ei(h,c))l.delete(a);else{if(n===0)break e;l.set(a,c)}t.right.delete(i)}else t.currentAttributes.set(a,c);break}default:n0){let a="";for(;n>0;n--)a+=` +`;t.right=new le(W(o,ue(s.store,o)),t.left,t.left&&t.left.lastId,t.right,t.right&&t.right.id,e,null,new Dt(a)),t.right.integrate(i,0),t.forward()}um(i,e,t,l)},Om=(i,e,t,n,r)=>{let s=e,o=pe();for(;s&&(!s.countable||s.deleted);){if(!s.deleted&&s.content.constructor===me){let c=s.content;o.set(c.key,c)}s=s.right}let l=0,a=!1;for(;e!==s;){if(t===e&&(a=!0),!e.deleted){let c=e.content;if(c.constructor===me){let{key:h,value:f}=c,u=n.get(h)??null;(o.get(h)!==c||u===f)&&(e.delete(i),l++,!a&&(r.get(h)??null)===f&&u!==f&&(u===null?r.delete(h):r.set(h,u))),!a&&!e.deleted&&Zr(r,c)}}e=e.right}return l},YQ=(i,e)=>{for(;e&&e.right&&(e.right.deleted||!e.right.countable);)e=e.right;let t=new Set;for(;e&&(e.deleted||!e.countable);){if(!e.deleted&&e.content.constructor===me){let n=e.content.key;t.has(n)?e.delete(i):t.add(n)}e=e.left}},gm=i=>{let e=0;return oe(i.doc,t=>{let n=i._start,r=i._start,s=pe(),o=ll(s);for(;r;)r.deleted===!1&&(r.content.constructor===me?Zr(o,r.content):(e+=Om(t,n,r,s,o),s=ll(o),n=r)),r=r.right}),e},zQ=i=>{let e=new Set,t=i.doc;for(let[n,r]of i.afterState.entries()){let s=i.beforeState.get(n)||0;r!==s&&Bg(i,t.store.clients.get(n),s,r,o=>{!o.deleted&&o.content.constructor===me&&o.constructor!==Be&&e.add(o.parent)})}oe(t,n=>{xn(i,i.deleteSet,r=>{if(r instanceof Be||!r.parent._hasFormatting||e.has(r.parent))return;let s=r.parent;r.content.constructor===me?e.add(s):YQ(n,r)});for(let r of e)gm(r)})},Rg=(i,e,t)=>{let n=t,r=ll(e.currentAttributes),s=e.right;for(;t>0&&e.right!==null;){if(e.right.deleted===!1)switch(e.right.content.constructor){case Pt:case mi:case Dt:t{r===null?this.childListChanged=!0:this.keysChanged.add(r)})}get changes(){if(this._changes===null){let e={keys:this.keys,delta:this.delta,added:new Set,deleted:new Set};this._changes=e}return this._changes}get delta(){if(this._delta===null){let e=this.target.doc,t=[];oe(e,n=>{let r=new Map,s=new Map,o=this.target._start,l=null,a={},c="",h=0,f=0,u=()=>{if(l!==null){let d=null;switch(l){case"delete":f>0&&(d={delete:f}),f=0;break;case"insert":(typeof c=="object"||c.length>0)&&(d={insert:c},r.size>0&&(d.attributes={},r.forEach((p,O)=>{p!==null&&(d.attributes[O]=p)}))),c="";break;case"retain":h>0&&(d={retain:h},VO(a)||(d.attributes=UO({},a))),h=0;break}d&&t.push(d),l=null}};for(;o!==null;){switch(o.content.constructor){case Pt:case mi:this.adds(o)?this.deletes(o)||(u(),l="insert",c=o.content.getContent()[0],u()):this.deletes(o)?(l!=="delete"&&(u(),l="delete"),f+=1):o.deleted||(l!=="retain"&&(u(),l="retain"),h+=1);break;case Dt:this.adds(o)?this.deletes(o)||(l!=="insert"&&(u(),l="insert"),c+=o.content.str):this.deletes(o)?(l!=="delete"&&(u(),l="delete"),f+=o.length):o.deleted||(l!=="retain"&&(u(),l="retain"),h+=o.length);break;case me:{let{key:d,value:p}=o.content;if(this.adds(o)){if(!this.deletes(o)){let O=r.get(d)??null;Ei(O,p)?p!==null&&o.delete(n):(l==="retain"&&u(),Ei(p,s.get(d)??null)?delete a[d]:a[d]=p)}}else if(this.deletes(o)){s.set(d,p);let O=r.get(d)??null;Ei(O,p)||(l==="retain"&&u(),a[d]=O)}else if(!o.deleted){s.set(d,p);let O=a[d];O!==void 0&&(Ei(O,p)?O!==null&&o.delete(n):(l==="retain"&&u(),p===null?delete a[d]:a[d]=p))}o.deleted||(l==="insert"&&u(),Zr(r,o.content));break}}o=o.right}for(u();t.length>0;){let d=t[t.length-1];if(d.retain!==void 0&&d.attributes===void 0)t.pop();else break}}),this._delta=t}return this._delta}},Ar=class i extends ge{constructor(e){super(),this._pending=e!==void 0?[()=>this.insert(0,e)]:[],this._searchMarker=[],this._hasFormatting=!1}get length(){return this.doc??Ee(),this._length}_integrate(e,t){super._integrate(e,t);try{this._pending.forEach(n=>n())}catch(n){console.error(n)}this._pending=null}_copy(){return new i}clone(){let e=new i;return e.applyDelta(this.toDelta()),e}_callObserver(e,t){super._callObserver(e,t);let n=new ql(this,e,t);ra(this,e,n),!e.local&&this._hasFormatting&&(e._needFormattingCleanup=!0)}toString(){this.doc??Ee();let e="",t=this._start;for(;t!==null;)!t.deleted&&t.countable&&t.content.constructor===Dt&&(e+=t.content.str),t=t.right;return e}toJSON(){return this.toString()}applyDelta(e,{sanitize:t=!0}={}){this.doc!==null?oe(this.doc,n=>{let r=new Ns(null,this._start,0,new Map);for(let s=0;s0)&&Wh(n,this,r,l,o.attributes||{})}else o.retain!==void 0?Mg(n,this,r,o.retain,o.attributes||{}):o.delete!==void 0&&Rg(n,r,o.delete)}}):this._pending.push(()=>this.applyDelta(e))}toDelta(e,t,n){this.doc??Ee();let r=[],s=new Map,o=this.doc,l="",a=this._start;function c(){if(l.length>0){let f={},u=!1;s.forEach((p,O)=>{u=!0,f[O]=p});let d={insert:l};u&&(d.attributes=f),r.push(d),l=""}}let h=()=>{for(;a!==null;){if(Ri(a,e)||t!==void 0&&Ri(a,t))switch(a.content.constructor){case Dt:{let f=s.get("ychange");e!==void 0&&!Ri(a,e)?(f===void 0||f.user!==a.id.client||f.type!=="removed")&&(c(),s.set("ychange",n?n("removed",a.id):{type:"removed"})):t!==void 0&&!Ri(a,t)?(f===void 0||f.user!==a.id.client||f.type!=="added")&&(c(),s.set("ychange",n?n("added",a.id):{type:"added"})):f!==void 0&&(c(),s.delete("ychange")),l+=a.content.str;break}case Pt:case mi:{c();let f={insert:a.content.getContent()[0]};if(s.size>0){let u={};f.attributes=u,s.forEach((d,p)=>{u[p]=d})}r.push(f);break}case me:Ri(a,e)&&(c(),Zr(s,a.content));break}a=a.right}c()};return e||t?oe(o,f=>{e&&Fh(f,e),t&&Fh(f,t),h()},"cleanup"):h(),r}insert(e,t,n){if(t.length<=0)return;let r=this.doc;r!==null?oe(r,s=>{let o=Ll(s,this,e,!n);n||(n={},o.currentAttributes.forEach((l,a)=>{n[a]=l})),Wh(s,this,o,t,n)}):this._pending.push(()=>this.insert(e,t,n))}insertEmbed(e,t,n){let r=this.doc;r!==null?oe(r,s=>{let o=Ll(s,this,e,!n);Wh(s,this,o,t,n||{})}):this._pending.push(()=>this.insertEmbed(e,t,n||{}))}delete(e,t){if(t===0)return;let n=this.doc;n!==null?oe(n,r=>{Rg(r,Ll(r,this,e,!0),t)}):this._pending.push(()=>this.delete(e,t))}format(e,t,n){if(t===0)return;let r=this.doc;r!==null?oe(r,s=>{let o=Ll(s,this,e,!1);o.right!==null&&Mg(s,this,o,t,n)}):this._pending.push(()=>this.format(e,t,n))}removeAttribute(e){this.doc!==null?oe(this.doc,t=>{zl(t,this,e)}):this._pending.push(()=>this.removeAttribute(e))}setAttribute(e,t){this.doc!==null?oe(this.doc,n=>{Of(n,this,e,t)}):this._pending.push(()=>this.setAttribute(e,t))}getAttribute(e){return gf(this,e)}getAttributes(){return cm(this)}_write(e){e.writeTypeRef(hv)}},WQ=i=>new Ar,Ds=class{constructor(e,t=()=>!0){this._filter=t,this._root=e,this._currentNode=e._start,this._firstCall=!0,e.doc??Ee()}[Symbol.iterator](){return this}next(){let e=this._currentNode,t=e&&e.content&&e.content.type;if(e!==null&&(!this._firstCall||e.deleted||!this._filter(t)))do if(t=e.content.type,!e.deleted&&(t.constructor===Cn||t.constructor===Pn)&&t._start!==null)e=t._start;else for(;e!==null;){let n=e.next;if(n!==null){e=n;break}else e.parent===this._root?e=null:e=e.parent._item}while(e!==null&&(e.deleted||!this._filter(e.content.type)));return this._firstCall=!1,e===null?{value:void 0,done:!0}:(this._currentNode=e,{value:e.content.type,done:!1})}},Pn=class i extends ge{constructor(){super(),this._prelimContent=[]}get firstChild(){let e=this._first;return e?e.content.getContent()[0]:null}_integrate(e,t){super._integrate(e,t),this.insert(0,this._prelimContent),this._prelimContent=null}_copy(){return new i}clone(){let e=new i;return e.insert(0,this.toArray().map(t=>t instanceof ge?t.clone():t)),e}get length(){return this.doc??Ee(),this._prelimContent===null?this._length:this._prelimContent.length}createTreeWalker(e){return new Ds(this,e)}querySelector(e){e=e.toUpperCase();let n=new Ds(this,r=>r.nodeName&&r.nodeName.toUpperCase()===e).next();return n.done?null:n.value}querySelectorAll(e){return e=e.toUpperCase(),_t(new Ds(this,t=>t.nodeName&&t.nodeName.toUpperCase()===e))}_callObserver(e,t){ra(this,e,new Gl(this,t,e))}toString(){return rm(this,e=>e.toString()).join("")}toJSON(){return this.toString()}toDOM(e=document,t={},n){let r=e.createDocumentFragment();return n!==void 0&&n._createAssociation(r,this),Ws(this,s=>{r.insertBefore(s.toDOM(e,t,n),null)}),r}insert(e,t){this.doc!==null?oe(this.doc,n=>{lm(n,this,e,t)}):this._prelimContent.splice(e,0,...t)}insertAfter(e,t){if(this.doc!==null)oe(this.doc,n=>{let r=e&&e instanceof ge?e._item:e;Yl(n,this,r,t)});else{let n=this._prelimContent,r=e===null?0:n.findIndex(s=>s===e)+1;if(r===0&&e!==null)throw dt("Reference item not found");n.splice(r,0,...t)}}delete(e,t=1){this.doc!==null?oe(this.doc,n=>{am(n,this,e,t)}):this._prelimContent.splice(e,t)}toArray(){return nm(this)}push(e){this.insert(this.length,e)}unshift(e){this.insert(0,e)}get(e){return sm(this,e)}slice(e=0,t=this.length){return im(this,e,t)}forEach(e){Ws(this,e)}_write(e){e.writeTypeRef(uv)}},NQ=i=>new Pn,Cn=class i extends Pn{constructor(e="UNDEFINED"){super(),this.nodeName=e,this._prelimAttrs=new Map}get nextSibling(){let e=this._item?this._item.next:null;return e?e.content.type:null}get prevSibling(){let e=this._item?this._item.prev:null;return e?e.content.type:null}_integrate(e,t){super._integrate(e,t),this._prelimAttrs.forEach((n,r)=>{this.setAttribute(r,n)}),this._prelimAttrs=null}_copy(){return new i(this.nodeName)}clone(){let e=new i(this.nodeName),t=this.getAttributes();return BO(t,(n,r)=>{e.setAttribute(r,n)}),e.insert(0,this.toArray().map(n=>n instanceof ge?n.clone():n)),e}toString(){let e=this.getAttributes(),t=[],n=[];for(let l in e)n.push(l);n.sort();let r=n.length;for(let l=0;l0?" "+t.join(" "):"";return`<${s}${o}>${super.toString()}`}removeAttribute(e){this.doc!==null?oe(this.doc,t=>{zl(t,this,e)}):this._prelimAttrs.delete(e)}setAttribute(e,t){this.doc!==null?oe(this.doc,n=>{Of(n,this,e,t)}):this._prelimAttrs.set(e,t)}getAttribute(e){return gf(this,e)}hasAttribute(e){return hm(this,e)}getAttributes(e){return e?fm(this,e):cm(this)}toDOM(e=document,t={},n){let r=e.createElement(this.nodeName),s=this.getAttributes();for(let o in s){let l=s[o];typeof l=="string"&&r.setAttribute(o,l)}return Ws(this,o=>{r.appendChild(o.toDOM(e,t,n))}),n!==void 0&&n._createAssociation(r,this),r}_write(e){e.writeTypeRef(fv),e.writeKey(this.nodeName)}},qQ=i=>new Cn(i.readKey()),Gl=class extends Qn{constructor(e,t,n){super(e,n),this.childListChanged=!1,this.attributesChanged=new Set,t.forEach(r=>{r===null?this.childListChanged=!0:this.attributesChanged.add(r)})}},qs=class i extends $n{constructor(e){super(),this.hookName=e}_copy(){return new i(this.hookName)}clone(){let e=new i(this.hookName);return this.forEach((t,n)=>{e.set(n,t)}),e}toDOM(e=document,t={},n){let r=t[this.hookName],s;return r!==void 0?s=r.createDom(this):s=document.createElement(this.hookName),s.setAttribute("data-yjs-hook",this.hookName),n!==void 0&&n._createAssociation(s,this),s}_write(e){e.writeTypeRef(dv),e.writeKey(this.hookName)}},GQ=i=>new qs(i.readKey()),Fl=class i extends Ar{get nextSibling(){let e=this._item?this._item.next:null;return e?e.content.type:null}get prevSibling(){let e=this._item?this._item.prev:null;return e?e.content.type:null}_copy(){return new i}clone(){let e=new i;return e.applyDelta(this.toDelta()),e}toDOM(e=document,t,n){let r=e.createTextNode(this.toString());return n!==void 0&&n._createAssociation(r,this),r}toString(){return this.toDelta().map(e=>{let t=[];for(let r in e.attributes){let s=[];for(let o in e.attributes[r])s.push({key:o,value:e.attributes[r][o]});s.sort((o,l)=>o.keyr.nodeName=0;r--)n+=``;return n}).join("")}toJSON(){return this.toString()}_write(e){e.writeTypeRef(pv)}},FQ=i=>new Fl,Mr=class{constructor(e,t){this.id=e,this.length=t}get deleted(){throw pt()}mergeWith(e){return!1}write(e,t,n){throw pt()}integrate(e,t){throw pt()}},HQ=0,Be=class extends Mr{get deleted(){return!0}delete(){}mergeWith(e){return this.constructor!==e.constructor?!1:(this.length+=e.length,!0)}integrate(e,t){t>0&&(this.id.clock+=t,this.length-=t),Ug(e.doc.store,this)}write(e,t){e.writeInfo(HQ),e.writeLen(this.length-t)}getMissing(e,t){return null}},Tn=class i{constructor(e){this.content=e}getLength(){return 1}getContent(){return[this.content]}isCountable(){return!0}copy(){return new i(this.content)}splice(e){throw pt()}mergeWith(e){return!1}integrate(e,t){}delete(e){}gc(e){}write(e,t){e.writeBuf(this.content)}getRef(){return 3}},KQ=i=>new Tn(i.readBuf()),Rr=class i{constructor(e){this.len=e}getLength(){return this.len}getContent(){return[]}isCountable(){return!1}copy(){return new i(this.len)}splice(e){let t=new i(this.len-e);return this.len=e,t}mergeWith(e){return this.len+=e.len,!0}integrate(e,t){js(e.deleteSet,t.id.client,t.id.clock,this.len),t.markDeleted()}delete(e){}gc(e){}write(e,t){e.writeLen(this.len-t)}getRef(){return 1}},JQ=i=>new Rr(i.readLen()),mm=(i,e)=>new _i({guid:i,...e,shouldLoad:e.shouldLoad||e.autoLoad||!1}),An=class i{constructor(e){e._item&&console.error("This document was already integrated as a sub-document. You should create a second instance instead with the same guid."),this.doc=e;let t={};this.opts=t,e.gc||(t.gc=!1),e.autoLoad&&(t.autoLoad=!0),e.meta!==null&&(t.meta=e.meta)}getLength(){return 1}getContent(){return[this.doc]}isCountable(){return!0}copy(){return new i(mm(this.doc.guid,this.opts))}splice(e){throw pt()}mergeWith(e){return!1}integrate(e,t){this.doc._item=t,e.subdocsAdded.add(this.doc),this.doc.shouldLoad&&e.subdocsLoaded.add(this.doc)}delete(e){e.subdocsAdded.has(this.doc)?e.subdocsAdded.delete(this.doc):e.subdocsRemoved.add(this.doc)}gc(e){}write(e,t){e.writeString(this.doc.guid),e.writeAny(this.opts)}getRef(){return 9}},ev=i=>new An(mm(i.readString(),i.readAny())),mi=class i{constructor(e){this.embed=e}getLength(){return 1}getContent(){return[this.embed]}isCountable(){return!0}copy(){return new i(this.embed)}splice(e){throw pt()}mergeWith(e){return!1}integrate(e,t){}delete(e){}gc(e){}write(e,t){e.writeJSON(this.embed)}getRef(){return 5}},tv=i=>new mi(i.readJSON()),me=class i{constructor(e,t){this.key=e,this.value=t}getLength(){return 1}getContent(){return[]}isCountable(){return!1}copy(){return new i(this.key,this.value)}splice(e){throw pt()}mergeWith(e){return!1}integrate(e,t){let n=t.parent;n._searchMarker=null,n._hasFormatting=!0}delete(e){}gc(e){}write(e,t){e.writeKey(this.key),e.writeJSON(this.value)}getRef(){return 6}},iv=i=>new me(i.readKey(),i.readJSON()),Gs=class i{constructor(e){this.arr=e}getLength(){return this.arr.length}getContent(){return this.arr}isCountable(){return!0}copy(){return new i(this.arr)}splice(e){let t=new i(this.arr.slice(e));return this.arr=this.arr.slice(0,e),t}mergeWith(e){return this.arr=this.arr.concat(e.arr),!0}integrate(e,t){}delete(e){}gc(e){}write(e,t){let n=this.arr.length;e.writeLen(n-t);for(let r=t;r{let e=i.readLen(),t=[];for(let n=0;n{let e=i.readLen(),t=[];for(let n=0;n=55296&&n<=56319&&(this.str=this.str.slice(0,e-1)+"\uFFFD",t.str="\uFFFD"+t.str.slice(1)),t}mergeWith(e){return this.str+=e.str,!0}integrate(e,t){}delete(e){}gc(e){}write(e,t){e.writeString(t===0?this.str:this.str.slice(t))}getRef(){return 4}},ov=i=>new Dt(i.readString()),lv=[BQ,VQ,WQ,qQ,NQ,GQ,FQ],av=0,cv=1,hv=2,fv=3,uv=4,dv=5,pv=6,Pt=class i{constructor(e){this.type=e}getLength(){return 1}getContent(){return[this.type]}isCountable(){return!0}copy(){return new i(this.type._copy())}splice(e){throw pt()}mergeWith(e){return!1}integrate(e,t){this.type._integrate(e.doc,t)}delete(e){let t=this.type._start;for(;t!==null;)t.deleted?t.id.clock<(e.beforeState.get(t.id.client)||0)&&e._mergeStructs.push(t):t.delete(e),t=t.right;this.type._map.forEach(n=>{n.deleted?n.id.clock<(e.beforeState.get(n.id.client)||0)&&e._mergeStructs.push(n):n.delete(e)}),e.changed.delete(this.type)}gc(e){let t=this.type._start;for(;t!==null;)t.gc(e,!0),t=t.right;this.type._start=null,this.type._map.forEach(n=>{for(;n!==null;)n.gc(e,!0),n=n.left}),this.type._map=new Map}write(e,t){this.type._write(e)}getRef(){return 7}},Ov=i=>new Pt(lv[i.readTypeRef()](i)),tf=(i,e)=>{let t=e,n=0,r;do n>0&&(t=W(t.client,t.clock+n)),r=Sn(i,t),n=t.clock-r.id.clock,t=r.redone;while(t!==null&&r instanceof le);return{item:r,diff:n}},mf=(i,e)=>{for(;i!==null&&i.keep!==e;)i.keep=e,i=i.parent._item},Hl=(i,e,t)=>{let{client:n,clock:r}=e.id,s=new le(W(n,r+t),e,W(n,r+t-1),e.right,e.rightOrigin,e.parent,e.parentSub,e.content.splice(t));return e.deleted&&s.markDeleted(),e.keep&&(s.keep=!0),e.redone!==null&&(s.redone=W(e.redone.client,e.redone.clock+t)),e.right=s,s.right!==null&&(s.right.left=s),i._mergeStructs.push(s),s.parentSub!==null&&s.right===null&&s.parent._map.set(s.parentSub,s),e.length=t,s},Xg=(i,e)=>ys(i,t=>Mn(t.deletions,e)),bm=(i,e,t,n,r,s)=>{let o=i.doc,l=o.store,a=o.clientID,c=e.redone;if(c!==null)return Ue(i,c);let h=e.parent._item,f=null,u;if(h!==null&&h.deleted===!0){if(h.redone===null&&(!t.has(h)||bm(i,h,t,n,r,s)===null))return null;for(;h.redone!==null;)h=Ue(i,h.redone)}let d=h===null?e.parent:h.content.type;if(e.parentSub===null){for(f=e.left,u=e;f!==null;){let b=f;for(;b!==null&&b.parent._item!==h;)b=b.redone===null?null:Ue(i,b.redone);if(b!==null&&b.parent._item===h){f=b;break}f=f.left}for(;u!==null;){let b=u;for(;b!==null&&b.parent._item!==h;)b=b.redone===null?null:Ue(i,b.redone);if(b!==null&&b.parent._item===h){u=b;break}u=u.right}}else{if(u=null,e.right&&!r){for(f=e;f!==null&&f.right!==null&&(f.right.redone||Mn(n,f.right.id)||Xg(s.undoStack,f.right.id)||Xg(s.redoStack,f.right.id));)for(f=f.right;f.redone;)f=Ue(i,f.redone);if(f&&f.right!==null)return null}else f=d._map.get(e.parentSub)||null;f!==null&&f.parent._item!==h&&(f=d._map.get(e.parentSub)||null)}let p=ue(l,a),O=W(a,p),g=new le(O,f,f&&f.lastId,u,u&&u.id,d,e.parentSub,e.content.copy());return e.redone=O,mf(g,!0),g.integrate(i,0),g},le=class i extends Mr{constructor(e,t,n,r,s,o,l,a){super(e,a.getLength()),this.origin=n,this.left=t,this.right=r,this.rightOrigin=s,this.parent=o,this.parentSub=l,this.redone=null,this.content=a,this.info=this.content.isCountable()?2:0}set marker(e){(this.info&8)>0!==e&&(this.info^=8)}get marker(){return(this.info&8)>0}get keep(){return(this.info&1)>0}set keep(e){this.keep!==e&&(this.info^=1)}get countable(){return(this.info&2)>0}get deleted(){return(this.info&4)>0}set deleted(e){this.deleted!==e&&(this.info^=4)}markDeleted(){this.info|=4}getMissing(e,t){if(this.origin&&this.origin.client!==this.id.client&&this.origin.clock>=ue(t,this.origin.client))return this.origin.client;if(this.rightOrigin&&this.rightOrigin.client!==this.id.client&&this.rightOrigin.clock>=ue(t,this.rightOrigin.client))return this.rightOrigin.client;if(this.parent&&this.parent.constructor===Oi&&this.id.client!==this.parent.client&&this.parent.clock>=ue(t,this.parent.client))return this.parent.client;if(this.origin&&(this.left=Kh(e,t,this.origin),this.origin=this.left.lastId),this.rightOrigin&&(this.right=Ue(e,this.rightOrigin),this.rightOrigin=this.right.id),this.left&&this.left.constructor===Be||this.right&&this.right.constructor===Be)this.parent=null;else if(!this.parent)this.left&&this.left.constructor===i?(this.parent=this.left.parent,this.parentSub=this.left.parentSub):this.right&&this.right.constructor===i&&(this.parent=this.right.parent,this.parentSub=this.right.parentSub);else if(this.parent.constructor===Oi){let n=Sn(t,this.parent);n.constructor===Be?this.parent=null:this.parent=n.content.type}return null}integrate(e,t){if(t>0&&(this.id.clock+=t,this.left=Kh(e,e.doc.store,W(this.id.client,this.id.clock-1)),this.origin=this.left.lastId,this.content=this.content.splice(t),this.length-=t),this.parent){if(!this.left&&(!this.right||this.right.left!==null)||this.left&&this.left.right!==this.right){let n=this.left,r;if(n!==null)r=n.right;else if(this.parentSub!==null)for(r=this.parent._map.get(this.parentSub)||null;r!==null&&r.left!==null;)r=r.left;else r=this.parent._start;let s=new Set,o=new Set;for(;r!==null&&r!==this.right;){if(o.add(r),s.add(r),yn(this.origin,r.origin)){if(r.id.client{n.p===e&&(n.p=this,!this.deleted&&this.countable&&(n.index-=this.length))}),e.keep&&(this.keep=!0),this.right=e.right,this.right!==null&&(this.right.left=this),this.length+=e.length,!0}return!1}delete(e){if(!this.deleted){let t=this.parent;this.countable&&this.parentSub===null&&(t._length-=this.length),this.markDeleted(),js(e.deleteSet,this.id.client,this.id.clock,this.length),$g(e,t,this.parentSub),this.content.delete(e)}}gc(e,t){if(!this.deleted)throw Ce();this.content.gc(e),t?wQ(e,this,new Be(this.id,this.length)):this.content=new Rr(this.length)}write(e,t){let n=t>0?W(this.id.client,this.id.clock+t-1):this.origin,r=this.rightOrigin,s=this.parentSub,o=this.content.getRef()&31|(n===null?0:128)|(r===null?0:64)|(s===null?0:32);if(e.writeInfo(o),n!==null&&e.writeLeftID(n),r!==null&&e.writeRightID(r),n===null&&r===null){let l=this.parent;if(l._item!==void 0){let a=l._item;if(a===null){let c=hf(l);e.writeParentInfo(!0),e.writeString(c)}else e.writeParentInfo(!1),e.writeLeftID(a.id)}else l.constructor===String?(e.writeParentInfo(!0),e.writeString(l)):l.constructor===Oi?(e.writeParentInfo(!1),e.writeLeftID(l)):Ce();s!==null&&e.writeString(s)}this.content.write(e,t)}},ym=(i,e)=>gv[e&31](i),gv=[()=>{Ce()},JQ,nv,KQ,ov,tv,iv,Ov,sv,ev,()=>{Ce()}],mv=10,Xe=class extends Mr{get deleted(){return!0}delete(){}mergeWith(e){return this.constructor!==e.constructor?!1:(this.length+=e.length,!0)}integrate(e,t){Ce()}write(e,t){e.writeInfo(mv),B(e.restEncoder,this.length-t)}getMissing(e,t){return null}},Sm=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:{},xm="__ $YJS$ __";Sm[xm]===!0&&console.error("Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438");Sm[xm]=!0;var bf=3e4,yf=class extends hl{constructor(e){super(),this.doc=e,this.clientID=e.clientID,this.states=new Map,this.meta=new Map,this._checkInterval=setInterval(()=>{let t=pi();this.getLocalState()!==null&&bf/2<=t-this.meta.get(this.clientID).lastUpdated&&this.setLocalState(this.getLocalState());let n=[];this.meta.forEach((r,s)=>{s!==this.clientID&&bf<=t-r.lastUpdated&&this.states.has(s)&&n.push(s)}),n.length>0&&wm(this,n,"timeout")},Re(bf/10)),e.on("destroy",()=>{this.destroy()}),this.setLocalState({})}destroy(){this.emit("destroy",[this]),this.setLocalState(null),super.destroy(),clearInterval(this._checkInterval)}getLocalState(){return this.states.get(this.clientID)||null}setLocalState(e){let t=this.clientID,n=this.meta.get(t),r=n===void 0?0:n.clock+1,s=this.states.get(t);e===null?this.states.delete(t):this.states.set(t,e),this.meta.set(t,{clock:r,lastUpdated:pi()});let o=[],l=[],a=[],c=[];e===null?c.push(t):s==null?e!=null&&o.push(t):(l.push(t),Mi(s,e)||a.push(t)),(o.length>0||a.length>0||c.length>0)&&this.emit("change",[{added:o,updated:a,removed:c},"local"]),this.emit("update",[{added:o,updated:l,removed:c},"local"])}setLocalStateField(e,t){let n=this.getLocalState();n!==null&&this.setLocalState({...n,[e]:t})}getStates(){return this.states}},wm=(i,e,t)=>{let n=[];for(let r=0;r0&&(i.emit("change",[{added:[],updated:[],removed:n},t]),i.emit("update",[{added:[],updated:[],removed:n},t]))},bv=(i,e,t=i.states)=>{let n=e.length,r=di();B(r,n);for(let s=0;s{let n=Qe(e),r=pi(),s=[],o=[],l=[],a=[],c=z(n);for(let h=0;h0||l.length>0||a.length>0)&&i.emit("change",[{added:s,updated:l,removed:a},t]),(s.length>0||o.length>0||a.length>0)&&i.emit("update",[{added:s,updated:o,removed:a},t])};var xf=[],$m=[];(()=>{let i="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,1n,9,16,o,,x,1i,3,,i,,7,a,2,t,3,1k,,,7,2,2,2,3,9,,a,2,q,,2,3,1k,,,5,4,2,2,3,3,,u,2,3,,b,3,1k,,,8,,3,,3,k,2,m,6,,3,1k,,,7,2,2,2,3,7,3,a,2,u,,1n,5,3,3,,4,9,,14,5,1j,,,7,,3,,4,7,2,b,2,t,3,1k,,,7,,3,,4,7,2,b,2,f,,c,4,1j,2,,7,,3,,4,9,,a,2,t,3,1y,,4,6,,,,8,i,2,1p,,,8,c,8,2q,,,a,b,7,21,2,r,,,,,,4,2,1d,k,,2,5,b,,10,9,,2u,b,,6,n,4,4,3,g,4,d,,,3,6,,f,,jj,3,qa,4,s,3,t,2,u,2,1s,w,9,,19,3,,,39,2,y,,3a,c,4,c,63,5,1l,a,,,,,2,o,2,,1c,1a,2,c,k,5,1b,h,12,9,c,3,u,d,1k,e,1c,k,48,3,,l,4,,6,,2,3,5i,1s,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,n,5,4,,2b,2,1e,i,q,i,d,,12,8,p,d,18,4,1b,e,10,,1v,e,c,,8,2,1a,,1f,,,3,2,2,5,2,,,15,5,5,2,6k,8,,2,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,1t,5,8t,2,25,6,1y,b,1d,4,3e,3,1h,f,15,,2,2,a,4,19,b,7,,1p,3,10,e,g,2,18,,c,3,1c,e,8,4,,2,2k,c,6,,2,,4d,c,l,4,1j,2,,7,2,2,2,3,9,,a,2,2,7,3,5,1v,9,,,2,,,4,,5,,,e,2,2a,i,n,,29,k,6j,7,2,9,r,2,2a,h,2y,d,2t,3,2,a,74,f,6t,6,,2,2,4,,,,2,3x,7,2,7,3,,s,a,14,7,,4,8,,9,b,1a,g,5i,8,5j,8,,8,2a,m,,e,3e,6,3,,,2,,7,,,1u,5,,2,,5,9n,4,9,2,,,1c,7,3,5,n,,44l,,6,f,8ug,i,1xc,5,1n,7,t4,,,1j,7,4,29,,b,2,f57,2,3mp,1a,2,n,f2,5,3,6,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,2s,,4g,7,af,,1p,4,e4,4,72,2,6r,,2,,7,2,5,,d6,7,31,7,240,5".split(",").map(e=>e?parseInt(e,36):1);for(let e=0,t=0;e>1;if(i=$m[n])e=n+1;else return!0;if(e==t)return!1}}function km(i){return i>=127462&&i<=127487}var Qm=8205;function Pm(i,e,t=!0,n=!0){return(t?Cm:xv)(i,e,n)}function Cm(i,e,t){if(e==i.length)return e;e&&Tm(i.charCodeAt(e))&&Am(i.charCodeAt(e-1))&&e--;let n=Sf(i,e);for(e+=vm(n);e=0&&km(Sf(i,o));)s++,o-=2;if(s%2==0)break;e+=2}else break}return e}function xv(i,e,t){for(;e>1;){let n=Cm(i,e-2,t);if(n=56320&&i<57344}function Am(i){return i>=55296&&i<56320}function vm(i){return i<65536?1:2}var q=class i{lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,n){[e,t]=Ur(this,e,t);let r=[];return this.decompose(0,e,r,2),n.length&&n.decompose(0,n.length,r,3),this.decompose(t,this.length,r,1),Dr.from(r,this.length-(t-e)+n.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){[e,t]=Ur(this,e,t);let n=[];return this.decompose(e,t,n,0),Dr.from(n,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),n=this.length-this.scanIdentical(e,-1),r=new Zn(this),s=new Zn(e);for(let o=t,l=t;;){if(r.next(o),s.next(o),o=0,r.lineBreak!=s.lineBreak||r.done!=s.done||r.value!=s.value)return!1;if(l+=r.value.length,r.done||l>=n)return!0}}iter(e=1){return new Zn(this,e)}iterRange(e,t=this.length){return new ca(this,e,t)}iterLines(e,t){let n;if(e==null)n=this.iter();else{t==null&&(t=this.lines+1);let r=this.line(e).from;n=this.iterRange(r,Math.max(r,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new ha(n)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?i.empty:e.length<=32?new gt(e):Dr.from(gt.split(e,[]))}},gt=class i extends q{constructor(e,t=wv(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,n,r){for(let s=0;;s++){let o=this.text[s],l=r+o.length;if((t?n:l)>=e)return new kf(r,l,n,o);r=l+1,n++}}decompose(e,t,n,r){let s=e<=0&&t>=this.length?this:new i(Mm(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(r&1){let o=n.pop(),l=aa(s.text,o.text.slice(),0,s.length);if(l.length<=32)n.push(new i(l,o.length+s.length));else{let a=l.length>>1;n.push(new i(l.slice(0,a)),new i(l.slice(a)))}}else n.push(s)}replace(e,t,n){if(!(n instanceof i))return super.replace(e,t,n);[e,t]=Ur(this,e,t);let r=aa(this.text,aa(n.text,Mm(this.text,0,e)),t),s=this.length+n.length-(t-e);return r.length<=32?new i(r,s):Dr.from(i.split(r,[]),s)}sliceString(e,t=this.length,n=` +`){[e,t]=Ur(this,e,t);let r="";for(let s=0,o=0;s<=t&&oe&&o&&(r+=n),es&&(r+=l.slice(Math.max(0,e-s),t-s)),s=a+1}return r}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let n=[],r=-1;for(let s of e)n.push(s),r+=s.length+1,n.length==32&&(t.push(new i(n,r)),n=[],r=-1);return r>-1&&t.push(new i(n,r)),t}},Dr=class i extends q{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let n of e)this.lines+=n.lines}lineInner(e,t,n,r){for(let s=0;;s++){let o=this.children[s],l=r+o.length,a=n+o.lines-1;if((t?a:l)>=e)return o.lineInner(e,t,n,r);r=l+1,n=a+1}}decompose(e,t,n,r){for(let s=0,o=0;o<=t&&s=o){let c=r&((o<=e?1:0)|(a>=t?2:0));o>=e&&a<=t&&!c?n.push(l):l.decompose(e-o,t-o,n,c)}o=a+1}}replace(e,t,n){if([e,t]=Ur(this,e,t),n.lines=s&&t<=l){let a=o.replace(e-s,t-s,n),c=this.lines-o.lines+a.lines;if(a.lines>4&&a.lines>c>>6){let h=this.children.slice();return h[r]=a,new i(h,this.length-(t-e)+n.length)}return super.replace(s,l,a)}s=l+1}return super.replace(e,t,n)}sliceString(e,t=this.length,n=` +`){[e,t]=Ur(this,e,t);let r="";for(let s=0,o=0;se&&s&&(r+=n),eo&&(r+=l.sliceString(e-o,t-o,n)),o=a+1}return r}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof i))return 0;let n=0,[r,s,o,l]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;r+=t,s+=t){if(r==o||s==l)return n;let a=this.children[r],c=e.children[s];if(a!=c)return n+a.scanIdentical(c,t);n+=a.length+1}}static from(e,t=e.reduce((n,r)=>n+r.length+1,-1)){let n=0;for(let d of e)n+=d.lines;if(n<32){let d=[];for(let p of e)p.flatten(d);return new gt(d,t)}let r=Math.max(32,n>>5),s=r<<1,o=r>>1,l=[],a=0,c=-1,h=[];function f(d){let p;if(d.lines>s&&d instanceof i)for(let O of d.children)f(O);else d.lines>o&&(a>o||!a)?(u(),l.push(d)):d instanceof gt&&a&&(p=h[h.length-1])instanceof gt&&d.lines+p.lines<=32?(a+=d.lines,c+=d.length+1,h[h.length-1]=new gt(p.text.concat(d.text),p.length+1+d.length)):(a+d.lines>r&&u(),a+=d.lines,c+=d.length+1,h.push(d))}function u(){a!=0&&(l.push(h.length==1?h[0]:i.from(h,c)),c=-1,a=h.length=0)}for(let d of e)f(d);return u(),l.length==1?l[0]:new i(l,t)}};q.empty=new gt([""],0);function wv(i){let e=-1;for(let t of i)e+=t.length+1;return e}function aa(i,e,t=0,n=1e9){for(let r=0,s=0,o=!0;s=t&&(a>n&&(l=l.slice(0,n-r)),r0?1:(e instanceof gt?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let n=this.nodes.length-1,r=this.nodes[n],s=this.offsets[n],o=s>>1,l=r instanceof gt?r.text.length:r.children.length;if(o==(t>0?l:0)){if(n==0)return this.done=!0,this.value="",this;t>0&&this.offsets[n-1]++,this.nodes.pop(),this.offsets.pop()}else if((s&1)==(t>0?0:1)){if(this.offsets[n]+=t,e==0)return this.lineBreak=!0,this.value=` +`,this;e--}else if(r instanceof gt){let a=r.text[o+(t<0?-1:0)];if(this.offsets[n]+=t,a.length>Math.max(0,e))return this.value=e==0?a:t>0?a.slice(e):a.slice(0,a.length-e),this;e-=a.length}else{let a=r.children[o+(t<0?-1:0)];e>a.length?(e-=a.length,this.offsets[n]+=t):(t<0&&this.offsets[n]--,this.nodes.push(a),this.offsets.push(t>0?1:(a instanceof gt?a.text.length:a.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}},ca=class{constructor(e,t,n){this.value="",this.done=!1,this.cursor=new Zn(e,t>n?-1:1),this.pos=t>n?e.length:0,this.from=Math.min(t,n),this.to=Math.max(t,n)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let n=t<0?this.pos-this.from:this.to-this.pos;e>n&&(e=n),n-=e;let{value:r}=this.cursor.next(e);return this.pos+=(r.length+e)*t,this.value=r.length<=n?r:t<0?r.slice(r.length-n):r.slice(0,n),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}},ha=class{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:n,value:r}=this.inner.next(e);return t&&this.afterBreak?(this.value="",this.afterBreak=!1):t?(this.done=!0,this.value=""):n?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=r,this.afterBreak=!1),this}get lineBreak(){return!1}};typeof Symbol<"u"&&(q.prototype[Symbol.iterator]=function(){return this.iter()},Zn.prototype[Symbol.iterator]=ca.prototype[Symbol.iterator]=ha.prototype[Symbol.iterator]=function(){return this});var kf=class{constructor(e,t,n,r){this.from=e,this.to=t,this.number=n,this.text=r}get length(){return this.to-this.from}};function Ur(i,e,t){return e=Math.max(0,Math.min(i.length,e)),[e,Math.max(e,Math.min(i.length,t))]}function be(i,e,t=!0,n=!0){return Pm(i,e,t,n)}function kv(i){return i>=56320&&i<57344}function Qv(i){return i>=55296&&i<56320}function Ze(i,e){let t=i.charCodeAt(e);if(!Qv(t)||e+1==i.length)return t;let n=i.charCodeAt(e+1);return kv(n)?(t-55296<<10)+(n-56320)+65536:t}function io(i){return i<=65535?String.fromCharCode(i):(i-=65536,String.fromCharCode((i>>10)+55296,(i&1023)+56320))}function mt(i){return i<65536?1:2}var Qf=/\r\n?|\n/,$e=(function(i){return i[i.Simple=0]="Simple",i[i.TrackDel=1]="TrackDel",i[i.TrackBefore=2]="TrackBefore",i[i.TrackAfter=3]="TrackAfter",i})($e||($e={})),yi=class i{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;te)return s+(e-r);s+=l}else{if(n!=$e.Simple&&c>=e&&(n==$e.TrackDel&&re||n==$e.TrackBefore&&re))return null;if(c>e||c==e&&t<0&&!l)return e==r||t<0?s:s+a;s+=a}r=c}if(e>r)throw new RangeError(`Position ${e} is out of range for changeset of length ${r}`);return s}touchesRange(e,t=e){for(let n=0,r=0;n=0&&r<=t&&l>=e)return rt?"cover":!0;r=l}return!1}toString(){let e="";for(let t=0;t=0?":"+r:"")}return e}toJSON(){return this.sections}static fromJSON(e){if(!Array.isArray(e)||e.length%2||e.some(t=>typeof t!="number"))throw new RangeError("Invalid JSON representation of ChangeDesc");return new i(e)}static create(e){return new i(e)}},He=class i extends yi{constructor(e,t){super(e),this.inserted=t}apply(e){if(this.length!=e.length)throw new RangeError("Applying change set to a document with the wrong length");return vf(this,(t,n,r,s,o)=>e=e.replace(r,r+(n-t),o),!1),e}mapDesc(e,t=!1){return $f(this,e,t,!0)}invert(e){let t=this.sections.slice(),n=[];for(let r=0,s=0;r=0){t[r]=l,t[r+1]=o;let a=r>>1;for(;n.length0&&ji(n,t,s.text),s.forward(h),l+=h}let c=e[o++];for(;l>1].toJSON()))}return e}static of(e,t,n){let r=[],s=[],o=0,l=null;function a(h=!1){if(!h&&!r.length)return;ou||f<0||u>t)throw new RangeError(`Invalid change range ${f} to ${u} (in doc of length ${t})`);let p=d?typeof d=="string"?q.of(d.split(n||Qf)):d:q.empty,O=p.length;if(f==u&&O==0)return;fo&&Ve(r,f-o,-1),Ve(r,u-f,O),ji(s,r,p),o=u}}return c(e),a(!l),l}static empty(e){return new i(e?[e,-1]:[],[])}static fromJSON(e){if(!Array.isArray(e))throw new RangeError("Invalid JSON representation of ChangeSet");let t=[],n=[];for(let r=0;rl&&typeof o!="string"))throw new RangeError("Invalid JSON representation of ChangeSet");if(s.length==1)t.push(s[0],0);else{for(;n.length=0&&t<=0&&t==i[r+1]?i[r]+=e:r>=0&&e==0&&i[r]==0?i[r+1]+=t:n?(i[r]+=e,i[r+1]+=t):i.push(e,t)}function ji(i,e,t){if(t.length==0)return;let n=e.length-2>>1;if(n>1])),!(t||o==i.sections.length||i.sections[o+1]<0);)l=i.sections[o++],a=i.sections[o++];e(r,c,s,h,f),r=c,s=h}}}function $f(i,e,t,n=!1){let r=[],s=n?[]:null,o=new _n(i),l=new _n(e);for(let a=-1;;){if(o.done&&l.len||l.done&&o.len)throw new Error("Mismatched change set lengths");if(o.ins==-1&&l.ins==-1){let c=Math.min(o.len,l.len);Ve(r,c,-1),o.forward(c),l.forward(c)}else if(l.ins>=0&&(o.ins<0||a==o.i||o.off==0&&(l.len=0&&a=0){let c=0,h=o.len;for(;h;)if(l.ins==-1){let f=Math.min(h,l.len);c+=f,h-=f,l.forward(f)}else if(l.ins==0&&l.lena||o.ins>=0&&o.len>a)&&(l||n.length>c),s.forward2(a),o.forward(a)}}}}var _n=class{constructor(e){this.set=e,this.i=0,this.next()}next(){let{sections:e}=this.set;this.i>1;return t>=e.length?q.empty:e[t]}textBit(e){let{inserted:t}=this.set,n=this.i-2>>1;return n>=t.length&&!e?q.empty:t[n].slice(this.off,e==null?void 0:this.off+e)}forward(e){e==this.len?this.next():(this.len-=e,this.off+=e)}forward2(e){this.ins==-1?this.forward(e):e==this.ins?this.next():(this.ins-=e,this.off+=e)}},Rn=class i{constructor(e,t,n,r){this.from=e,this.to=t,this.flags=n,this.goalColumn=r}get anchor(){return this.flags&32?this.to:this.from}get head(){return this.flags&32?this.from:this.to}get empty(){return this.from==this.to}get assoc(){return this.flags&8?-1:this.flags&16?1:0}get undirectional(){return(this.flags&64)>0}get bidiLevel(){let e=this.flags&7;return e==7?null:e}map(e,t=-1){let n,r;return this.empty?n=r=e.mapPos(this.from,t):(n=e.mapPos(this.from,1),r=e.mapPos(this.to,-1)),n==this.from&&r==this.to?this:new i(n,r,this.flags,this.goalColumn)}extend(e,t=e,n=0){if(e<=this.anchor&&t>=this.anchor)return v.range(e,t,void 0,void 0,n);let r=Math.abs(e-this.anchor)>Math.abs(t-this.anchor)?e:t;return v.range(this.anchor,r,void 0,void 0,n)}eq(e,t=!1){return this.anchor==e.anchor&&this.head==e.head&&this.goalColumn==e.goalColumn&&(!t||!this.empty||this.assoc==e.assoc)}toJSON(){return{anchor:this.anchor,head:this.head}}static fromJSON(e){if(!e||typeof e.anchor!="number"||typeof e.head!="number")throw new RangeError("Invalid JSON representation for SelectionRange");return v.range(e.anchor,e.head)}static create(e,t,n,r){return new i(e,t,n,r)}},v=class i{constructor(e,t){this.ranges=e,this.mainIndex=t}map(e,t=-1){return e.empty?this:i.create(this.ranges.map(n=>n.map(e,t)),this.mainIndex)}eq(e,t=!1){if(this.ranges.length!=e.ranges.length||this.mainIndex!=e.mainIndex)return!1;for(let n=0;ne.toJSON()),main:this.mainIndex}}static fromJSON(e){if(!e||!Array.isArray(e.ranges)||typeof e.main!="number"||e.main>=e.ranges.length)throw new RangeError("Invalid JSON representation for EditorSelection");return new i(e.ranges.map(t=>Rn.fromJSON(t)),e.main)}static single(e,t=e){return new i([i.range(e,t)],0)}static create(e,t=0){if(e.length==0)throw new RangeError("A selection needs at least one range");for(let n=0,r=0;rr.from-s.from),t=e.indexOf(n);for(let r=1;rs.head?i.range(a,l):i.range(l,a))}}return new i(e,t)}};function Lm(i,e){for(let t of i.ranges)if(t.to>e)throw new RangeError("Selection points outside of document")}var _f=0,R=class i{constructor(e,t,n,r,s){this.combine=e,this.compareInput=t,this.compare=n,this.isStatic=r,this.id=_f++,this.default=e([]),this.extensions=typeof s=="function"?s(this):s}get reader(){return this}static define(e={}){return new i(e.combine||(t=>t),e.compareInput||((t,n)=>t===n),e.compare||(e.combine?(t,n)=>t===n:Lf),!!e.static,e.enables)}of(e){return new jr([],this,0,e)}compute(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new jr(e,this,1,t)}computeN(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new jr(e,this,2,t)}from(e,t){return t||(t=n=>n),this.compute([e],n=>t(n.field(e)))}};function Lf(i,e){return i==e||i.length==e.length&&i.every((t,n)=>t===e[n])}var jr=class{constructor(e,t,n,r){this.dependencies=e,this.facet=t,this.type=n,this.value=r,this.id=_f++}dynamicSlot(e){var t;let n=this.value,r=this.facet.compareInput,s=this.id,o=e[s]>>1,l=this.type==2,a=!1,c=!1,h=[];for(let f of this.dependencies)f=="doc"?a=!0:f=="selection"?c=!0:(((t=e[f.id])!==null&&t!==void 0?t:1)&1)==0&&h.push(e[f.id]);return{create(f){return f.values[o]=n(f),1},update(f,u){if(a&&u.docChanged||c&&(u.docChanged||u.selection)||Pf(f,h)){let d=n(f);if(l?!Rm(d,f.values[o],r):!r(d,f.values[o]))return f.values[o]=d,1}return 0},reconfigure:(f,u)=>{let d,p=u.config.address[s];if(p!=null){let O=pa(u,p);if(this.dependencies.every(g=>g instanceof R?u.facet(g)===f.facet(g):g instanceof de?u.field(g,!1)==f.field(g,!1):!0)||(l?Rm(d=n(f),O,r):r(d=n(f),O)))return f.values[o]=O,0}else d=n(f);return f.values[o]=d,1}}}get extension(){return this}};function Rm(i,e,t){if(i.length!=e.length)return!1;for(let n=0;ni[a.id]),r=t.map(a=>a.type),s=n.filter(a=>!(a&1)),o=i[e.id]>>1;function l(a){let c=[];for(let h=0;hn===r),e);return e.provide&&(t.provides=e.provide(t)),t}create(e){let t=e.facet(sa).find(n=>n.field==this);return(t?.create||this.createF)(e)}slot(e){let t=e[this.id]>>1;return{create:n=>(n.values[t]=this.create(n),1),update:(n,r)=>{let s=n.values[t],o=this.updateF(s,r);return this.compareF(s,o)?0:(n.values[t]=o,1)},reconfigure:(n,r)=>{let s=n.facet(sa),o=r.facet(sa),l;return(l=s.find(a=>a.field==this))&&l!=o.find(a=>a.field==this)?(n.values[t]=l.create(n),1):r.config.address[this.id]!=null?(n.values[t]=r.field(this),0):(n.values[t]=this.create(n),1)}}}init(e){return[this,sa.of({field:this,create:e})]}get extension(){return this}},Xn={lowest:4,low:3,default:2,high:1,highest:0};function Hs(i){return e=>new fa(e,i)}var at={highest:Hs(Xn.highest),high:Hs(Xn.high),default:Hs(Xn.default),low:Hs(Xn.low),lowest:Hs(Xn.lowest)},fa=class{constructor(e,t){this.inner=e,this.prec=t}get extension(){return this}},ua=class i{of(e){return new Js(this,e)}reconfigure(e){return i.reconfigure.of({compartment:this,extension:e})}get(e){return e.config.compartments.get(this)}},Js=class{constructor(e,t){this.compartment=e,this.inner=t}get extension(){return this}},da=class i{constructor(e,t,n,r,s,o){for(this.base=e,this.compartments=t,this.dynamicSlots=n,this.address=r,this.staticValues=s,this.facets=o,this.statusTemplate=[];this.statusTemplate.length>1]}static resolve(e,t,n){let r=[],s=Object.create(null),o=new Map;for(let u of $v(e,t,o))u instanceof de?r.push(u):(s[u.facet.id]||(s[u.facet.id]=[])).push(u);let l=Object.create(null),a=[],c=[];for(let u of r)l[u.id]=c.length<<1,c.push(d=>u.slot(d));let h=n?.config.facets;for(let u in s){let d=s[u],p=d[0].facet,O=h&&h[u]||[];if(d.every(g=>g.type==0))if(l[p.id]=a.length<<1|1,Lf(O,d))a.push(n.facet(p));else{let g=p.combine(d.map(b=>b.value));a.push(n&&p.compare(g,n.facet(p))?n.facet(p):g)}else{for(let g of d)g.type==0?(l[g.id]=a.length<<1|1,a.push(g.value)):(l[g.id]=c.length<<1,c.push(b=>g.dynamicSlot(b)));l[p.id]=c.length<<1,c.push(g=>vv(g,p,d))}}let f=c.map(u=>u(l));return new i(e,o,f,l,a,s)}};function $v(i,e,t){let n=[[],[],[],[],[]],r=new Map;function s(o,l){let a=r.get(o);if(a!=null){if(a<=l)return;let c=n[a].indexOf(o);c>-1&&n[a].splice(c,1),o instanceof Js&&t.delete(o.compartment)}if(r.set(o,l),Array.isArray(o))for(let c of o)s(c,l);else if(o instanceof Js){if(t.has(o.compartment))throw new RangeError("Duplicate use of compartment in extensions");let c=e.get(o.compartment)||o.inner;t.set(o.compartment,c),s(c,l)}else if(o instanceof fa)s(o.inner,o.prec);else if(o instanceof de)n[l].push(o),o.provides&&s(o.provides,l);else if(o instanceof jr)n[l].push(o),o.facet.extensions&&s(o.facet.extensions,Xn.default);else{let c=o.extension;if(!c)throw new Error(`Unrecognized extension value in extension set (${o}).`);if(c==o)throw new Error(`Unrecognized extension value in extension set (${o}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.`);s(c,l)}}return s(i,Xn.default),n.reduce((o,l)=>o.concat(l))}function Ks(i,e){if(e&1)return 2;let t=e>>1,n=i.status[t];if(n==4)throw new Error("Cyclic dependency between fields and/or facets");if(n&2)return n;i.status[t]=4;let r=i.computeSlot(i,i.config.dynamicSlots[t]);return i.status[t]=2|r}function pa(i,e){return e&1?i.config.staticValues[e>>1]:i.values[e>>1]}var Dm=R.define(),Cf=R.define({combine:i=>i.some(e=>e),static:!0}),jm=R.define({combine:i=>i.length?i[0]:void 0,static:!0}),Im=R.define(),Um=R.define(),Bm=R.define(),Vm=R.define({combine:i=>i.length?i[0]:!1}),we=class{constructor(e,t){this.type=e,this.value=t}static define(){return new Tf}},Tf=class{of(e){return new we(this,e)}},Af=class{constructor(e){this.map=e}of(e){return new Y(this,e)}},Y=class i{constructor(e,t){this.type=e,this.value=t}map(e){let t=this.type.map(this.value,e);return t===void 0?void 0:t==this.value?this:new i(this.type,t)}is(e){return this.type==e}static define(e={}){return new Af(e.map||(t=>t))}static mapEffects(e,t){if(!e.length)return e;let n=[];for(let r of e){let s=r.map(t);s&&n.push(s)}return n}};Y.reconfigure=Y.define();Y.appendConfig=Y.define();var xe=class i{constructor(e,t,n,r,s,o){this.startState=e,this.changes=t,this.selection=n,this.effects=r,this.annotations=s,this.scrollIntoView=o,this._doc=null,this._state=null,n&&Lm(n,t.newLength),s.some(l=>l.type==i.time)||(this.annotations=s.concat(i.time.of(Date.now())))}static create(e,t,n,r,s,o){return new i(e,t,n,r,s,o)}get newDoc(){return this._doc||(this._doc=this.changes.apply(this.startState.doc))}get newSelection(){return this.selection||this.startState.selection.map(this.changes)}get state(){return this._state||this.startState.applyTransaction(this),this._state}annotation(e){for(let t of this.annotations)if(t.type==e)return t.value}get docChanged(){return!this.changes.empty}get reconfigured(){return this.startState.config!=this.state.config}isUserEvent(e){let t=this.annotation(i.userEvent);return!!(t&&(t==e||t.length>e.length&&t.slice(0,e.length)==e&&t[e.length]=="."))}};xe.time=we.define();xe.userEvent=we.define();xe.addToHistory=we.define();xe.remote=we.define();function Pv(i,e){let t=[];for(let n=0,r=0;;){let s,o;if(n=i[n]))s=i[n++],o=i[n++];else if(r=0;r--){let s=n[r](i);s instanceof xe?i=s:Array.isArray(s)&&s.length==1&&s[0]instanceof xe?i=s[0]:i=zm(e,Ir(s),!1)}return i}function Tv(i){let e=i.startState,t=e.facet(Bm),n=i;for(let r=t.length-1;r>=0;r--){let s=t[r](i);s&&Object.keys(s).length&&(n=Ym(n,Mf(e,s,i.changes.newLength),!0))}return n==i?i:xe.create(e,i.changes,i.selection,n.effects,n.annotations,n.scrollIntoView)}var Av=[];function Ir(i){return i==null?Av:Array.isArray(i)?i:[i]}var ae=(function(i){return i[i.Word=0]="Word",i[i.Space=1]="Space",i[i.Other=2]="Other",i})(ae||(ae={})),Mv=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/,Rf;try{Rf=new RegExp("[\\p{Alphabetic}\\p{Number}_]","u")}catch{}function Rv(i){if(Rf)return Rf.test(i);for(let e=0;e"\x80"&&(t.toUpperCase()!=t.toLowerCase()||Mv.test(t)))return!0}return!1}function Xv(i){return e=>{if(!/\S/.test(e))return ae.Space;if(Rv(e))return ae.Word;for(let t=0;t-1)return ae.Word;return ae.Other}}var ne=class i{constructor(e,t,n,r,s,o){this.config=e,this.doc=t,this.selection=n,this.values=r,this.status=e.statusTemplate.slice(),this.computeSlot=s,o&&(o._state=this);for(let l=0;lr.set(c,a)),t=null),r.set(l.value.compartment,l.value.extension)):l.is(Y.reconfigure)?(t=null,n=l.value):l.is(Y.appendConfig)&&(t=null,n=Ir(n).concat(l.value));let s;t?s=e.startState.values.slice():(t=da.resolve(n,r,this),s=new i(t,this.doc,this.selection,t.dynamicSlots.map(()=>null),(a,c)=>c.reconfigure(a,this),null).values);let o=e.startState.facet(Cf)?e.newSelection:e.newSelection.asSingle();new i(t,e.newDoc,o,s,(l,a)=>a.update(l,e),e)}replaceSelection(e){return typeof e=="string"&&(e=this.toText(e)),this.changeByRange(t=>({changes:{from:t.from,to:t.to,insert:e},range:v.cursor(t.from+e.length,-1)}))}changeByRange(e){let t=this.selection,n=e(t.ranges[0]),r=this.changes(n.changes),s=[n.range],o=Ir(n.effects);for(let l=1;lo.spec.fromJSON(l,a)))}}return i.create({doc:e.doc,selection:v.fromJSON(e.selection),extensions:t.extensions?r.concat([t.extensions]):r})}static create(e={}){let t=da.resolve(e.extensions||[],new Map),n=e.doc instanceof q?e.doc:q.of((e.doc||"").split(t.staticFacet(i.lineSeparator)||Qf)),r=e.selection?e.selection instanceof v?e.selection:v.single(e.selection.anchor,e.selection.head):v.single(0);return Lm(r,n.length),t.staticFacet(Cf)||(r=r.asSingle()),new i(t,n,r,t.dynamicSlots.map(()=>null),(s,o)=>o.create(s),null)}get tabSize(){return this.facet(i.tabSize)}get lineBreak(){return this.facet(i.lineSeparator)||` +`}get readOnly(){return this.facet(Vm)}phrase(e,...t){for(let n of this.facet(i.phrases))if(Object.prototype.hasOwnProperty.call(n,e)){e=n[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(n,r)=>{if(r=="$")return"$";let s=+(r||1);return!s||s>t.length?n:t[s-1]})),e}languageDataAt(e,t,n=-1){let r=[];for(let s of this.facet(Dm))for(let o of s(this,t,n))Object.prototype.hasOwnProperty.call(o,e)&&r.push(o[e]);return r}charCategorizer(e){let t=this.languageDataAt("wordChars",e);return Xv(t.length?t[0]:"")}wordAt(e){let{text:t,from:n,length:r}=this.doc.lineAt(e),s=this.charCategorizer(e),o=e-n,l=e-n;for(;o>0;){let a=be(t,o,!1);if(s(t.slice(a,o))!=ae.Word)break;o=a}for(;li.length?i[0]:4});ne.lineSeparator=jm;ne.readOnly=Vm;ne.phrases=R.define({compare(i,e){let t=Object.keys(i),n=Object.keys(e);return t.length==n.length&&t.every(r=>i[r]==e[r])}});ne.languageData=Dm;ne.changeFilter=Im;ne.transactionFilter=Um;ne.transactionExtender=Bm;ua.reconfigure=Y.define();function Ye(i,e,t={}){let n={};for(let r of i)for(let s of Object.keys(r)){let o=r[s],l=n[s];if(l===void 0)n[s]=o;else if(!(l===o||o===void 0))if(Object.hasOwnProperty.call(t,s))n[s]=t[s](l,o);else throw new Error("Config merge conflict for field "+s)}for(let r in e)n[r]===void 0&&(n[r]=e[r]);return n}var Ct=class{eq(e){return this==e}range(e,t=e){return eo.create(e,t,this)}};Ct.prototype.startSide=Ct.prototype.endSide=0;Ct.prototype.point=!1;Ct.prototype.mapMode=$e.TrackDel;function Df(i,e){return i==e||i.constructor==e.constructor&&i.eq(e)}var eo=class i{constructor(e,t,n){this.from=e,this.to=t,this.value=n}static create(e,t,n){return new i(e,t,n)}};function Xf(i,e){return i.from-e.from||i.value.startSide-e.value.startSide}var Ef=class i{constructor(e,t,n,r){this.from=e,this.to=t,this.value=n,this.maxPoint=r}get length(){return Lr(this.to)}findIndex(e,t,n,r=0){let s=n?this.to:this.from;for(let o=r,l=s.length;;){if(o==l)return o;let a=o+l>>1,c=s[a]-e||(n?this.value[a].endSide:this.value[a].startSide)-t;if(a==o)return c>=0?o:l;c>=0?l=a:o=a+1}}between(e,t,n,r){for(let s=this.findIndex(t,-1e9,!0),o=this.findIndex(n,1e9,!1,s);sg||O==g&&u.startSide>0&&u.endSide<=0)continue;if(!((g-O||u.endSide-u.startSide)<0))if(c<0&&(c=O),u.point&&(h=Math.max(h,g-O)),(O-n||u.startSide-r)>=0)o.push(u),l.push(O-c),a.push(g-c),n=g,r=u.endSide;else{if(O==g)for(let b=o.length;b>0;b--){if((O-(a[b-1]+c)||u.startSide-o[b-1].endSide)>=0){o.splice(b,0,u),l.splice(b,0,O-c),a.splice(b,0,g-c);continue e}if((O-(l[b-1]+c)||u.endSide-o[b-1].startSide)>0)break}s(O,g,u)}}return{mapped:o.length?new i(l,a,o,h):null,pos:c}}},G=class i{constructor(e,t,n,r){this.chunkPos=e,this.chunk=t,this.nextLayer=n,this.maxPoint=r}static create(e,t,n,r){return new i(e,t,n,r)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:n=!1,filterFrom:r=0,filterTo:s=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(n&&(t=t.slice().sort(Xf)),this.isEmpty)return t.length?i.of(t):this;let l=new Oa(this,null,-1).goto(0),a=0,c=[],h=new lt;for(;l.value||a=0){let f=t[a++];h.addInner(f.from,f.to,f.value,!1)||c.push(f)}else l.rangeIndex==1&&l.chunkIndexthis.chunkEnd(l.chunkIndex)||sl.to||s{s||(s=new lt),s.addRange(a,c,h,!1)};for(let a=0;a=s&&e<=s+o.length&&o.between(s,e-s,t-s,n)===!1)return}this.nextLayer.between(e,t,n)}}iter(e=0){return to.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return to.from(e).goto(t)}static compare(e,t,n,r,s=-1){let o=e.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=s),l=t.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=s),a=Xm(o,l,n),c=new En(o,a,s),h=new En(l,a,s);n.iterGaps((f,u,d)=>Em(c,f,h,u,d,r)),n.empty&&n.length==0&&Em(c,0,h,0,0,r)}static eq(e,t,n=0,r){r==null&&(r=999999999);let s=e.filter(h=>!h.isEmpty&&t.indexOf(h)<0),o=t.filter(h=>!h.isEmpty&&e.indexOf(h)<0);if(s.length!=o.length)return!1;if(!s.length)return!0;let l=Xm(s,o),a=new En(s,l,0).goto(n),c=new En(o,l,0).goto(n);for(;;){if(a.to!=c.to||!Zf(a.active,c.active)||a.point&&(!c.point||!Df(a.point,c.point)))return!1;if(a.to>r)return!0;a.next(),c.next()}}static spans(e,t,n,r,s=-1){let o=new En(e,null,s).goto(t),l=t,a=o.openStart;for(;;){let c=Math.min(o.to,n);if(o.point){let h=o.activeForPoint(o.to),f=o.pointFroml&&(r.span(l,c,o.active,a),a=o.openEnd(c));if(o.to>n)return a+(o.point&&o.to>n?1:0);l=o.to,o.next()}}static of(e,t=!1){let n=new lt;for(let r of e instanceof eo?[e]:t?Ev(e):e)n.add(r.from,r.to,r.value);return n.finish()}static join(e){if(!e.length)return i.empty;let t=Lr(e);for(let n=e.length-2;n>=0;n--)for(let r=e[n];r!=i.empty;r=r.nextLayer)t=new i(r.chunkPos,r.chunk,t,Math.max(r.maxPoint,t.maxPoint));return t}};G.empty=new G([],[],null,-1);function Lr(i){return i[i.length-1]}function Ev(i){if(i.length>1)for(let e=i[0],t=1;t0)return i.slice().sort(Xf);e=n}return i}G.empty.nextLayer=G.empty;var lt=class i{finishChunk(e){this.chunks.push(new Ef(this.from,this.to,this.value,this.maxPoint)),this.chunkPos.push(this.chunkStart),this.chunkStart=-1,this.setMaxPoint=Math.max(this.setMaxPoint,this.maxPoint),this.maxPoint=-1,e&&(this.from=[],this.to=[],this.value=[])}constructor(){this.chunks=[],this.chunkPos=[],this.chunkStart=-1,this.last=null,this.lastFrom=-1e9,this.lastTo=-1e9,this.from=[],this.to=[],this.value=[],this.maxPoint=-1,this.setMaxPoint=-1,this.nextLayer=null}add(e,t,n){this.addRange(e,t,n,!0)}addRange(e,t,n,r){this.addInner(e,t,n,r)||(this.nextLayer||(this.nextLayer=new i)).addRange(e,t,n,r)}addInner(e,t,n,r){let s=e-this.lastTo||n.startSide-this.last.endSide;if(r&&s<=0&&(e-this.lastFrom||n.startSide-this.last.startSide)<0)throw new Error("Ranges must be added sorted by `from` position and `startSide`");return s<0?!1:(this.from.length==250&&this.finishChunk(!0),this.chunkStart<0&&(this.chunkStart=e),this.from.push(e-this.chunkStart),this.to.push(t-this.chunkStart),this.last=n,this.lastFrom=e,this.lastTo=t,this.value.push(n),n.point&&(this.maxPoint=Math.max(this.maxPoint,t-e)),!0)}addChunk(e,t){if((e-this.lastTo||t.value[0].startSide-this.last.endSide)<0)return!1;this.from.length&&this.finishChunk(!0),this.setMaxPoint=Math.max(this.setMaxPoint,t.maxPoint),this.chunks.push(t),this.chunkPos.push(e);let n=t.value.length-1;return this.last=t.value[n],this.lastFrom=t.from[n]+e,this.lastTo=t.to[n]+e,!0}finish(){return this.finishInner(G.empty)}finishInner(e){if(this.from.length&&this.finishChunk(!1),this.chunks.length==0)return e;let t=G.create(this.chunkPos,this.chunks,this.nextLayer?this.nextLayer.finishInner(e):e,this.setMaxPoint);return this.from=null,t}};function Xm(i,e,t){let n=new Map;for(let s of i)for(let o=0;o=this.minPoint)break}}setRangeIndex(e){if(e==this.layer.chunk[this.chunkIndex].value.length){if(this.chunkIndex++,this.skip)for(;this.chunkIndex=n&&r.push(new Oa(o,t,n,s));return r.length==1?r[0]:new i(r)}get startSide(){return this.value?this.value.startSide:0}goto(e,t=-1e9){for(let n of this.heap)n.goto(e,t);for(let n=this.heap.length>>1;n>=0;n--)wf(this.heap,n);return this.next(),this}forward(e,t){for(let n of this.heap)n.forward(e,t);for(let n=this.heap.length>>1;n>=0;n--)wf(this.heap,n);(this.to-e||this.value.endSide-t)<0&&this.next()}next(){if(this.heap.length==0)this.from=this.to=1e9,this.value=null,this.rank=-1;else{let e=this.heap[0];this.from=e.from,this.to=e.to,this.value=e.value,this.rank=e.rank,e.value&&e.next(),wf(this.heap,0)}}};function wf(i,e){for(let t=i[e];;){let n=(e<<1)+1;if(n>=i.length)break;let r=i[n];if(n+1=0&&(r=i[n+1],n++),t.compare(r)<0)break;i[n]=t,i[e]=r,e=n}}var En=class{constructor(e,t,n){this.minPoint=n,this.active=[],this.activeTo=[],this.activeRank=[],this.minActive=-1,this.point=null,this.pointFrom=0,this.pointRank=0,this.to=-1e9,this.endSide=0,this.openStart=-1,this.cursor=to.from(e,t,n)}goto(e,t=-1e9){return this.cursor.goto(e,t),this.active.length=this.activeTo.length=this.activeRank.length=0,this.minActive=-1,this.to=e,this.endSide=t,this.openStart=-1,this.next(),this}forward(e,t){for(;this.minActive>-1&&(this.activeTo[this.minActive]-e||this.active[this.minActive].endSide-t)<0;)this.removeActive(this.minActive);this.cursor.forward(e,t)}removeActive(e){oa(this.active,e),oa(this.activeTo,e),oa(this.activeRank,e),this.minActive=Zm(this.active,this.activeTo)}addActive(e){let t=0,{value:n,to:r,rank:s}=this.cursor;for(;t0;)t++;la(this.active,t,n),la(this.activeTo,t,r),la(this.activeRank,t,s),e&&la(e,t,this.cursor.from),this.minActive=Zm(this.active,this.activeTo)}next(){let e=this.to,t=this.point;this.point=null;let n=this.openStart<0?[]:null;for(;;){let r=this.minActive;if(r>-1&&(this.activeTo[r]-this.cursor.from||this.active[r].endSide-this.cursor.startSide)<0){if(this.activeTo[r]>e){this.to=this.activeTo[r],this.endSide=this.active[r].endSide;break}this.removeActive(r),n&&oa(n,r)}else if(this.cursor.value)if(this.cursor.from>e){this.to=this.cursor.from,this.endSide=this.cursor.startSide;break}else{let s=this.cursor.value;if(!s.point)this.addActive(n),this.cursor.next();else if(t&&this.cursor.to==this.to&&this.cursor.from=0&&n[r]=0&&!(this.activeRank[n]e||this.activeTo[n]==e&&this.active[n].endSide>=this.point.endSide)&&t.push(this.active[n]);return t.reverse()}openEnd(e){let t=0;for(let n=this.activeTo.length-1;n>=0&&this.activeTo[n]>e;n--)t++;return t}};function Em(i,e,t,n,r,s){i.goto(e),t.goto(n);let o=n+r,l=n,a=n-e,c=!!s.boundChange;for(let h=!1;;){let f=i.to+a-t.to,u=f||i.endSide-t.endSide,d=u<0?i.to+a:t.to,p=Math.min(d,o);if(i.point||t.point?(i.point&&t.point&&Df(i.point,t.point)&&Zf(i.activeForPoint(i.to),t.activeForPoint(t.to))||s.comparePoint(l,p,i.point,t.point),h=!1):(h&&(s.boundChange(l),h=!1),p>l&&!Zf(i.active,t.active)&&s.compareRange(l,p,i.active,t.active),c&&po)break;l=d,u<=0&&i.next(),u>=0&&t.next()}}function Zf(i,e){if(i.length!=e.length)return!1;for(let t=0;t=e;n--)i[n+1]=i[n];i[e]=t}function Zm(i,e){let t=-1,n=1e9;for(let r=0;r=e)return r;if(r==i.length)break;s+=i.charCodeAt(r)==9?t-s%t:1,r=be(i,r)}return n===!0?-1:i.length}var Wm=typeof Symbol>"u"?"__\u037C":Symbol.for("\u037C"),If=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),Nm=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{},Tt=class{constructor(e,t){this.rules=[];let{finish:n}=t||{};function r(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function s(o,l,a,c){let h=[],f=/^@(\w+)\b/.exec(o[0]),u=f&&f[1]=="keyframes";if(f&&l==null)return a.push(o[0]+";");for(let d in l){let p=l[d];if(/&/.test(d))s(d.split(/,\s*/).map(O=>o.map(g=>O.replace(/&/,g))).reduce((O,g)=>O.concat(g)),p,a);else if(p&&typeof p=="object"){if(!f)throw new RangeError("The value of a property ("+d+") should be a primitive value.");s(r(d),p,h,u)}else p!=null&&h.push(d.replace(/_.*/,"").replace(/[A-Z]/g,O=>"-"+O.toLowerCase())+": "+p+";")}(h.length||u)&&a.push((n&&!f&&!c?o.map(n):o).join(", ")+" {"+h.join(" ")+"}")}for(let o in e)s(r(o),e[o],this.rules)}getRules(){return this.rules.join(` +`)}static newName(){let e=Nm[Wm]||1;return Nm[Wm]=e+1,"\u037C"+e.toString(36)}static mount(e,t,n){let r=e[If],s=n&&n.nonce;r?s&&r.setNonce(s):r=new Uf(e,s),r.mount(Array.isArray(t)?t:[t],e)}},qm=new Map,Uf=class{constructor(e,t){let n=e.ownerDocument||e,r=n.defaultView;if(!e.head&&e.adoptedStyleSheets&&r.CSSStyleSheet){let s=qm.get(n);if(s)return e[If]=s;this.sheet=new r.CSSStyleSheet,qm.set(n,this)}else this.styleTag=n.createElement("style"),t&&this.styleTag.setAttribute("nonce",t);this.modules=[],e[If]=this}mount(e,t){let n=this.sheet,r=0,s=0,o=!1;for(let l=0;l-1&&(this.modules.splice(c,1),o=!0,s--,c=-1),c==-1){if(this.modules.splice(s++,0,a),o=!0,n)for(let h=0;h",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},Zv=typeof navigator<"u"&&/Mac/.test(navigator.platform),_v=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(ke=0;ke<10;ke++)Si[48+ke]=Si[96+ke]=String(ke);var ke;for(ke=1;ke<=24;ke++)Si[ke+111]="F"+ke;var ke;for(ke=65;ke<=90;ke++)Si[ke]=String.fromCharCode(ke+32),Br[ke]=String.fromCharCode(ke);var ke;for(ma in Si)Br.hasOwnProperty(ma)||(Br[ma]=Si[ma]);var ma;function Gm(i){var e=Zv&&i.metaKey&&i.shiftKey&&!i.ctrlKey&&!i.altKey||_v&&i.shiftKey&&i.key&&i.key.length==1||i.key=="Unidentified",t=!e&&i.key||(i.shiftKey?Br:Si)[i.keyCode]||i.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function te(){var i=arguments[0];typeof i=="string"&&(i=document.createElement(i));var e=1,t=arguments[1];if(t&&typeof t=="object"&&t.nodeType==null&&!Array.isArray(t)){for(var n in t)if(Object.prototype.hasOwnProperty.call(t,n)){var r=t[n];typeof r=="string"?i.setAttribute(n,r):r!=null&&(i[n]=r)}e++}for(;e2),_={mac:Jm||/Mac/.test(Je.platform),windows:/Win/.test(Je.platform),linux:/Linux|X11/.test(Je.platform),ie:Ha,ie_version:Z0?Ff.documentMode||6:Kf?+Kf[1]:Hf?+Hf[1]:0,gecko:Hm,gecko_version:Hm?+(/Firefox\/(\d+)/.exec(Je.userAgent)||[0,0])[1]:0,chrome:!!Bf,chrome_version:Bf?+Bf[1]:0,ios:Jm,android:/Android\b/.test(Je.userAgent),webkit:Km,webkit_version:Km?+(/\bAppleWebKit\/(\d+)/.exec(Je.userAgent)||[0,0])[1]:0,safari:Jf,safari_version:Jf?+(/\bVersion\/(\d+(\.\d+)?)/.exec(Je.userAgent)||[0,0])[1]:0,tabSize:Ff.documentElement.style.tabSize!=null?"tab-size":"-moz-tab-size"};function zu(i,e){for(let t in i)t=="class"&&e.class?e.class+=" "+i.class:t=="style"&&e.style?e.style+=";"+i.style:e[t]=i[t];return e}var Xa=Object.create(null);function Wu(i,e,t){if(i==e)return!0;i||(i=Xa),e||(e=Xa);let n=Object.keys(i),r=Object.keys(e);if(n.length-(t&&n.indexOf(t)>-1?1:0)!=r.length-(t&&r.indexOf(t)>-1?1:0))return!1;for(let s of n)if(s!=t&&(r.indexOf(s)==-1||i[s]!==e[s]))return!1;return!0}function Lv(i,e){for(let t=i.attributes.length-1;t>=0;t--){let n=i.attributes[t].name;e[n]==null&&i.removeAttribute(n)}for(let t in e){let n=e[t];t=="style"?i.style.cssText=n:i.getAttribute(t)!=n&&i.setAttribute(t,n)}}function e0(i,e,t){let n=!1;if(e)for(let r in e)t&&r in t||(n=!0,r=="style"?i.style.cssText="":i.removeAttribute(r));if(t)for(let r in t)e&&e[r]==t[r]||(n=!0,r=="style"?i.style.cssText=t[r]:i.setAttribute(r,t[r]));return n}function Dv(i){let e=Object.create(null);for(let t=0;t0?3e8:-4e8:t>0?1e8:-1e8,new In(e,t,t,n,e.widget||null,!1)}static replace(e){let t=!!e.block,n,r;if(e.isBlockGap)n=-5e8,r=4e8;else{let{start:s,end:o}=_0(e,t);n=(s?t?-3e8:-1:5e8)-1,r=(o?t?2e8:1:-6e8)+1}return new In(e,n,r,t,e.widget||null,!0)}static line(e){return new mo(e)}static set(e,t=!1){return G.of(e,t)}hasHeight(){return this.widget?this.widget.estimatedHeight>-1:!1}};L.none=G.empty;var go=class i extends L{constructor(e){let{start:t,end:n}=_0(e);super(t?-1:5e8,n?1:-6e8,null,e),this.tagName=e.tagName||"span",this.attrs=e.class&&e.attributes?zu(e.attributes,{class:e.class}):e.class?{class:e.class}:e.attributes||Xa}eq(e){return this==e||e instanceof i&&this.tagName==e.tagName&&Wu(this.attrs,e.attrs)}range(e,t=e){if(e>=t)throw new RangeError("Mark decorations may not be empty");return super.range(e,t)}};go.prototype.point=!1;var mo=class i extends L{constructor(e){super(-2e8,-2e8,null,e)}eq(e){return e instanceof i&&this.spec.class==e.spec.class&&Wu(this.spec.attributes,e.spec.attributes)}range(e,t=e){if(t!=e)throw new RangeError("Line decoration ranges must be zero-length");return super.range(e,t)}};mo.prototype.mapMode=$e.TrackBefore;mo.prototype.point=!0;var In=class i extends L{constructor(e,t,n,r,s,o){super(t,n,s,e),this.block=r,this.isReplace=o,this.mapMode=r?t<=0?$e.TrackBefore:$e.TrackAfter:$e.TrackDel}get type(){return this.startSide!=this.endSide?Le.WidgetRange:this.startSide<=0?Le.WidgetBefore:Le.WidgetAfter}get heightRelevant(){return this.block||!!this.widget&&(this.widget.estimatedHeight>=5||this.widget.lineBreaks>0)}eq(e){return e instanceof i&&jv(this.widget,e.widget)&&this.block==e.block&&this.startSide==e.startSide&&this.endSide==e.endSide}range(e,t=e){if(this.isReplace&&(e>t||e==t&&this.startSide>0&&this.endSide<=0))throw new RangeError("Invalid range for replacement decoration");if(!this.isReplace&&t!=e)throw new RangeError("Widget decorations can only have zero-length ranges");return super.range(e,t)}};In.prototype.point=!0;function _0(i,e=!1){let{inclusiveStart:t,inclusiveEnd:n}=i;return t==null&&(t=i.inclusive),n==null&&(n=i.inclusive),{start:t??e,end:n??e}}function jv(i,e){return i==e||!!(i&&e&&i.compare(e))}function qr(i,e,t,n=0){let r=t.length-1;r>=0&&t[r]+n>=i?t[r]=Math.max(t[r],e):t.push(i,e)}var Ea=class i extends Ct{constructor(e,t,n){super(),this.tagName=e,this.attributes=t,this.rank=n}eq(e){return e==this||e instanceof i&&this.tagName==e.tagName&&Wu(this.attributes,e.attributes)}static create(e){return new i(e.tagName,e.attributes||Xa,e.rank==null?50:Math.max(0,Math.min(e.rank,100)))}static set(e,t=!1){return G.of(e,t)}};Ea.prototype.startSide=Ea.prototype.endSide=-1;function bo(i){let e;return i.nodeType==11?e=i.getSelection?i:i.ownerDocument:e=i,e.getSelection()}function eu(i,e){return e?i==e||i.contains(e.nodeType!=1?e.parentNode:e):!1}function oo(i,e){if(!e.anchorNode)return!1;try{return eu(i,e.anchorNode)}catch{return!1}}function Pa(i){return i.nodeType==3?yo(i,0,i.nodeValue.length).getClientRects():i.nodeType==1?i.getClientRects():[]}function lo(i,e,t,n){return t?t0(i,e,t,n,-1)||t0(i,e,t,n,1):!1}function Bi(i){for(var e=0;;e++)if(i=i.previousSibling,!i)return e}function Za(i){return i.nodeType==1&&/^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(i.nodeName)}function t0(i,e,t,n,r){for(;;){if(i==t&&e==n)return!0;if(e==(r<0?0:ki(i))){if(i.nodeName=="DIV")return!1;let s=i.parentNode;if(!s||s.nodeType!=1)return!1;e=Bi(i)+(r<0?0:1),i=s}else if(i.nodeType==1){if(i=i.childNodes[e+(r<0?-1:0)],i.nodeType==1&&i.contentEditable=="false")return!1;e=r<0?ki(i):0}else return!1}}function ki(i){return i.nodeType==3?i.nodeValue.length:i.childNodes.length}function _a(i,e){let{left:t,right:n}=i;if(t==n)return i;let r=e?t:n;return{left:r,right:r,top:i.top,bottom:i.bottom}}function Iv(i){let e=i.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:i.innerWidth,top:0,bottom:i.innerHeight}}function L0(i,e){let t=e.width/i.offsetWidth,n=e.height/i.offsetHeight;return(t>.995&&t<1.005||!isFinite(t)||Math.abs(e.width-i.offsetWidth)<1)&&(t=1),(n>.995&&n<1.005||!isFinite(n)||Math.abs(e.height-i.offsetHeight)<1)&&(n=1),{scaleX:t,scaleY:n}}function Uv(i,e,t,n,r,s,o,l){let a=i.ownerDocument,c=a.defaultView||window;for(let h=i,f=!1;h&&!f;)if(h.nodeType==1){let u,d=h==a.body,p=1,O=1;if(d)u=Iv(c);else{if(/^(fixed|sticky)$/.test(getComputedStyle(h).position)&&(f=!0),h.scrollHeight<=h.clientHeight&&h.scrollWidth<=h.clientWidth){h=h.assignedSlot||h.parentNode;continue}let w=h.getBoundingClientRect();({scaleX:p,scaleY:O}=L0(h,w)),u={left:w.left,right:w.left+h.clientWidth*p,top:w.top,bottom:w.top+h.clientHeight*O}}let g=0,b=0;if(r=="nearest")e.top0&&e.bottom>u.bottom+b&&(b=e.bottom-u.bottom+o)):e.bottom>u.bottom-o&&(b=e.bottom-u.bottom+o,t<0&&e.top-b0&&e.right>u.right+g&&(g=e.right-u.right+s)):e.right>u.right-s&&(g=e.right-u.right+s,t<0&&e.leftu.bottom||e.leftu.right)&&(e={left:Math.max(e.left,u.left),right:Math.min(e.right,u.right),top:Math.max(e.top,u.top),bottom:Math.min(e.bottom,u.bottom)}),h=h.assignedSlot||h.parentNode}else if(h.nodeType==11)h=h.host;else break}function D0(i,e=!0){let t=i.ownerDocument,n=null,r=null;for(let s=i.parentNode;s&&!(s==t.body||(!e||n)&&r);)if(s.nodeType==1)!r&&s.scrollHeight>s.clientHeight&&(r=s),e&&!n&&s.scrollWidth>s.clientWidth&&(n=s),s=s.assignedSlot||s.parentNode;else if(s.nodeType==11)s=s.host;else break;return{x:n,y:r}}var tu=class{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){let{anchorNode:t,focusNode:n}=e;this.set(t,Math.min(e.anchorOffset,t?ki(t):0),n,Math.min(e.focusOffset,n?ki(n):0))}set(e,t,n,r){this.anchorNode=e,this.anchorOffset=t,this.focusNode=n,this.focusOffset=r}};function j0(i){let e=[];for(let t=i;t;t=t.nodeType==11?t.host:t.parentNode)t.nodeType==1&&e.push({node:t,left:t.scrollLeft,top:t.scrollTop});return e}function I0(i,e=!0){for(let{node:t,left:n,top:r}of i)e&&t.scrollTop!=r&&(t.scrollTop=r),t.scrollLeft!=n&&(t.scrollLeft=n)}var Ln=null;_.safari&&_.safari_version>=26&&(Ln=!1);function U0(i){if(i.setActive)return i.setActive();if(Ln)return i.focus(Ln);let e=j0(i);i.focus(Ln==null?{get preventScroll(){return Ln={preventScroll:!0},!0}}:void 0),Ln||(Ln=!1,I0(e))}var i0;function yo(i,e,t=e){let n=i0||(i0=document.createRange());return n.setEnd(i,t),n.setStart(i,e),n}function Gr(i,e,t,n){let r={key:e,code:e,keyCode:t,which:t,cancelable:!0};n&&({altKey:r.altKey,ctrlKey:r.ctrlKey,shiftKey:r.shiftKey,metaKey:r.metaKey}=n);let s=new KeyboardEvent("keydown",r);s.synthetic=!0,i.dispatchEvent(s);let o=new KeyboardEvent("keyup",r);return o.synthetic=!0,i.dispatchEvent(o),s.defaultPrevented||o.defaultPrevented}function Bv(i){for(;i;){if(i&&(i.nodeType==9||i.nodeType==11&&i.host))return i;i=i.assignedSlot||i.parentNode}return null}function Vv(i,e){let t=e.focusNode,n=e.focusOffset;if(!t||e.anchorNode!=t||e.anchorOffset!=n)return!1;for(n=Math.min(n,ki(t));;)if(n){if(t.nodeType!=1)return!1;let r=t.childNodes[n-1];r.contentEditable=="false"?n--:(t=r,n=ki(t))}else{if(t==i)return!0;n=Bi(t),t=t.parentNode}}function B0(i){return i instanceof Window?i.pageYOffset>Math.max(0,i.document.documentElement.scrollHeight-i.innerHeight-4):i.scrollTop>Math.max(1,i.scrollHeight-i.clientHeight-4)}function V0(i,e){for(let t=i,n=e;;){if(t.nodeType==3&&n>0)return{node:t,offset:n};if(t.nodeType==1&&n>0){if(t.contentEditable=="false")return null;t=t.childNodes[n-1],n=ki(t)}else if(t.parentNode&&!Za(t))n=Bi(t),t=t.parentNode;else return null}}function Y0(i,e){for(let t=i,n=e;;){if(t.nodeType==3&&n=t){if(l.level==n)return o;(s<0||(r!=0?r<0?l.fromt:e[s].level>l.level))&&(s=o)}}if(s<0)throw new RangeError("Index out of range");return s}};function N0(i,e){if(i.length!=e.length)return!1;for(let t=0;t=0;O-=3)if(oi[O+1]==-d){let g=oi[O+2],b=g&2?r:g&4?g&1?s:r:0;b&&(ce[f]=ce[oi[O]]=b),l=O;break}}else{if(oi.length==189)break;oi[l++]=f,oi[l++]=u,oi[l++]=a}else if((p=ce[f])==2||p==1){let O=p==r;a=O?0:1;for(let g=l-3;g>=0;g-=3){let b=oi[g+2];if(b&2)break;if(O)oi[g+2]|=2;else{if(b&4)break;oi[g+2]|=4}}}}}function Gv(i,e,t,n){for(let r=0,s=n;r<=t.length;r++){let o=r?t[r-1].to:i,l=ra;)p==g&&(p=t[--O].from,g=O?t[O-1].to:i),ce[--p]=d;a=h}else s=c,a++}}}function nu(i,e,t,n,r,s,o){let l=n%2?2:1;if(n%2==r%2)for(let a=e,c=0;aa&&o.push(new Rt(a,O.from,d));let g=O.direction==Un!=!(d%2);ru(i,g?n+1:n,r,O.inner,O.from,O.to,o),a=O.to}p=O.to}else{if(p==t||(h?ce[p]!=l:ce[p]==l))break;p++}u?nu(i,a,p,n+1,r,u,o):ae;){let h=!0,f=!1;if(!c||a>s[c-1].to){let O=ce[a-1];O!=l&&(h=!1,f=O==16)}let u=!h&&l==1?[]:null,d=h?n:n+1,p=a;e:for(;;)if(c&&p==s[c-1].to){if(f)break e;let O=s[--c];if(!h)for(let g=O.from,b=c;;){if(g==e)break e;if(b&&s[b-1].to==g)g=s[--b].from;else{if(ce[g-1]==l)break e;break}}if(u)u.push(O);else{O.toce.length;)ce[ce.length]=256;let n=[],r=e==Un?0:1;return ru(i,r,r,t,0,i.length,n),n}function q0(i){return[new Rt(0,i,0)]}var G0="";function Hv(i,e,t,n,r){var s;if(!i.length)return null;let o=n.head-i.from,l;if(n.head==i.from&&n.assoc<0){if(!r)return null;o=e[l=0].side(!1,t)}else if(n.head==i.to&&n.assoc>0){if(r)return null;o=e[l=e.length-1].side(!0,t)}else l=Rt.find(e,o,(s=n.bidiLevel)!==null&&s!==void 0?s:-1,n.assoc);let a=e[l],c=a.side(r,t);if(o==c){let u=l+=r?1:-1;if(u<0||u>=e.length)return null;a=e[l=u],o=a.side(!r,t),c=a.side(r,t)}let h=be(i.text,o,a.forward(r,t));(ha.to)&&(h=c),G0=i.text.slice(Math.min(o,h),Math.max(o,h));let f=l==(r?e.length-1:0)?null:e[l+(r?1:-1)];if(h==c){if(!f)return r?v.cursor(i.to,1):v.cursor(i.from,-1);if(f.level+(r?0:1)i.some(e=>e)}),n1=R.define({combine:i=>i.some(e=>e)}),r1=R.define(),ao=class i{constructor(e,t,n,r,s,o=!1){this.range=e,this.y=t,this.x=n,this.yMargin=r,this.xMargin=s,this.isSnapshot=o}map(e){return e.empty?this:new i(this.range.map(e),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}clip(e){return this.range.to<=e.doc.length?this:new i(v.cursor(e.doc.length),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}},ba=Y.define({map:(i,e)=>i.map(e)}),s1=Y.define();function _e(i,e,t){let n=i.facet(J0);n.length?n[0](e):window.onerror&&window.onerror(String(e),t,void 0,void 0,e)||(t?console.error(t+":",e):console.error(e))}var xi=R.define({combine:i=>i.length?i[0]:!0}),Jv=0,Yr=R.define({combine(i){return i.filter((e,t)=>{for(let n=0;n{let a=[];return o&&a.push(Ka.of(c=>{let h=c.plugin(l);return h?o(h):L.none})),s&&a.push(s(l)),a})}static fromClass(e,t){return i.define((n,r)=>new e(n,r),t)}},co=class{constructor(e){this.spec=e,this.mustUpdate=null,this.value=null}get plugin(){return this.spec&&this.spec.plugin}update(e){if(this.value){if(this.mustUpdate){let t=this.mustUpdate;if(this.mustUpdate=null,this.value.update)try{this.value.update(t)}catch(n){if(_e(t.state,n,"CodeMirror plugin crashed"),this.value.destroy)try{this.value.destroy()}catch{}this.deactivate()}}}else if(this.spec)try{this.value=this.spec.plugin.create(e,this.spec.arg)}catch(t){_e(e.state,t,"CodeMirror plugin crashed"),this.deactivate()}return this}destroy(e){var t;if(!((t=this.value)===null||t===void 0)&&t.destroy)try{this.value.destroy()}catch(n){_e(e.state,n,"CodeMirror plugin crashed")}}deactivate(){this.spec=this.value=null}},o1=R.define(),Fu=R.define(),Ka=R.define(),l1=R.define(),Hu=R.define(),So=R.define(),a1=R.define();function n0(i,e){let t=i.state.facet(a1);if(!t.length)return t;let n=t.map(s=>s instanceof Function?s(i):s),r=[];return G.spans(n,e.from,e.to,{point(){},span(s,o,l,a){let c=s-e.from,h=o-e.from,f=r;for(let u=l.length-1;u>=0;u--,a--){let d=l[u].spec.bidiIsolate,p;if(d==null&&(d=Kv(e.text,c,h)),a>0&&f.length&&(p=f[f.length-1]).to==c&&p.direction==d)p.to=h,f=p.inner;else{let O={from:c,to:h,direction:d,inner:[]};f.push(O),f=O.inner}}}}),r}var c1=R.define();function Ku(i){let e=0,t=0,n=0,r=0;for(let s of i.state.facet(c1)){let o=s(i);o&&(o.left!=null&&(e=Math.max(e,o.left)),o.right!=null&&(t=Math.max(t,o.right)),o.top!=null&&(n=Math.max(n,o.top)),o.bottom!=null&&(r=Math.max(r,o.bottom)))}return{left:e,right:t,top:n,bottom:r}}var no=R.define(),It=class i{constructor(e,t,n,r){this.fromA=e,this.toA=t,this.fromB=n,this.toB=r}join(e){return new i(Math.min(this.fromA,e.fromA),Math.max(this.toA,e.toA),Math.min(this.fromB,e.fromB),Math.max(this.toB,e.toB))}addToSet(e){let t=e.length,n=this;for(;t>0;t--){let r=e[t-1];if(!(r.fromA>n.toA)){if(r.toAr.push(new It(s,o,l,a))),this.changedRanges=r}static create(e,t,n){return new i(e,t,n)}get viewportChanged(){return(this.flags&4)>0}get viewportMoved(){return(this.flags&8)>0}get heightChanged(){return(this.flags&2)>0}get geometryChanged(){return this.docChanged||(this.flags&18)>0}get focusChanged(){return(this.flags&1)>0}get docChanged(){return!this.changes.empty}get selectionSet(){return this.transactions.some(e=>e.selection)}get empty(){return this.flags==0&&this.transactions.length==0}},e$=[],fe=class{constructor(e,t,n=0){this.dom=e,this.length=t,this.flags=n,this.parent=null,e.cmTile=this}get breakAfter(){return this.flags&1}get children(){return e$}isWidget(){return!1}get isHidden(){return!1}isComposite(){return!1}isLine(){return!1}isText(){return!1}isBlock(){return!1}get domAttrs(){return null}sync(e){if(this.flags|=2,this.flags&4){this.flags&=-5;let t=this.domAttrs;t&&Lv(this.dom,t)}}toString(){return this.constructor.name+(this.children.length?`(${this.children})`:"")+(this.breakAfter?"#":"")}destroy(){this.parent=null}setDOM(e){this.dom=e,e.cmTile=this}get posAtStart(){return this.parent?this.parent.posBefore(this):0}get posAtEnd(){return this.posAtStart+this.length}posBefore(e,t=this.posAtStart){let n=t;for(let r of this.children){if(r==e)return n;n+=r.length+r.breakAfter}throw new RangeError("Invalid child in posBefore")}posAfter(e){return this.posBefore(e)+e.length}covers(e){return!0}coordsIn(e,t,n){return null}domPosFor(e,t){let n=Bi(this.dom),r=this.length?e>0:t>0;return new li(this.parent.dom,n+(r?1:0),e==0||e==this.length)}markDirty(e){this.flags&=-3,e&&(this.flags|=4),this.parent&&this.parent.flags&2&&this.parent.markDirty(!1)}get overrideDOMText(){return null}get root(){for(let e=this;e;e=e.parent)if(e instanceof Hr)return e;return null}static get(e){return e.cmTile}},Fr=class extends fe{constructor(e){super(e,0),this._children=[]}isComposite(){return!0}get children(){return this._children}get lastChild(){return this.children.length?this.children[this.children.length-1]:null}append(e){this.children.push(e),e.parent=this}sync(e){if(this.flags&2)return;super.sync(e);let t=this.dom,n=null,r,s=e?.node==t?e:null,o=0;for(let l of this.children){if(l.sync(e),o+=l.length+l.breakAfter,r=n?n.nextSibling:t.firstChild,s&&r!=l.dom&&(s.written=!0),l.dom.parentNode==t)for(;r&&r!=l.dom;)r=r0(r);else t.insertBefore(l.dom,r);n=l.dom}for(r=n?n.nextSibling:t.firstChild,s&&r&&(s.written=!0);r;)r=r0(r);this.length=o}};function r0(i){let e=i.nextSibling;return i.parentNode.removeChild(i),e}var Hr=class extends Fr{constructor(e,t){super(t),this.view=e}owns(e){for(;e;e=e.parent)if(e==this)return!0;return!1}isBlock(){return!0}nearest(e){for(;;){if(!e)return null;let t=fe.get(e);if(t&&this.owns(t))return t;e=e.parentNode}}blockTiles(e){for(let t=[],n=this,r=0,s=0;;)if(r==n.children.length){if(!t.length)return;n=n.parent,n.breakAfter&&s++,r=t.pop()}else{let o=n.children[r++];if(o instanceof wi)t.push(r),n=o,r=0;else{let l=s+o.length,a=e(o,s);if(a!==void 0)return a;s=l+o.breakAfter}}}resolveBlock(e,t){let n,r=-1,s,o=-1;if(this.blockTiles((l,a)=>{let c=a+l.length;if(e>=a&&e<=c){if(l.isWidget()&&t>=-1&&t<=1){if(l.flags&32)return!0;l.flags&16&&(n=void 0)}(ae||e==a&&(t>1?l.length:l.covers(-1)))&&(!s||!l.isWidget()&&s.isWidget())&&(s=l,o=e-a)}}),!n&&!s)throw new Error("No tile at position "+e);return n&&t<0||!s?{tile:n,offset:r}:{tile:s,offset:o}}},wi=class i extends Fr{constructor(e,t){super(e),this.wrapper=t}isBlock(){return!0}covers(e){return this.children.length?e<0?this.children[0].covers(-1):this.lastChild.covers(1):!1}get domAttrs(){return this.wrapper.attributes}static of(e,t){let n=new i(t||document.createElement(e.tagName),e);return t||(n.flags|=4),n}},Kr=class i extends Fr{constructor(e,t){super(e),this.attrs=t}isLine(){return!0}static start(e,t,n){let r=new i(t||document.createElement("div"),e);return(!t||!n)&&(r.flags|=4),r}get domAttrs(){return this.attrs}resolveInline(e,t,n){let r=null,s=-1,o=null,l=-1;function a(h,f){for(let u=0,d=0;u=f&&(p.isComposite()?a(p,f-d):(!o||o.isHidden&&(t>0&&!(o.flags&32)||n&&i$(o,p)))&&(O>f||p.flags&32&&t<=1)?(o=p,l=f-d):(d=-1)&&(r=p,s=f-d)),d=O}}a(this,e);let c=(t<0?r:o)||r||o;return c?{tile:c,offset:c==r?s:l}:null}coordsIn(e,t,n){let r=this.resolveInline(e,t,!0);return r?r.tile.coordsIn(Math.max(0,r.offset),t,n):t$(this)}domIn(e,t){let n=this.resolveInline(e,t);if(n){let{tile:r,offset:s}=n;if(this.dom.contains(r.dom))return r.isText()?new li(r.dom,Math.min(r.dom.nodeValue.length,s)):r.domPosFor(s,r.flags&16?1:r.flags&32?-1:t);let o=n.tile.parent,l=!1;for(let a of o.children){if(l)return new li(a.dom,0);a==n.tile&&(l=!0)}}return new li(this.dom,0)}};function t$(i){let e=i.dom.lastChild;if(!e)return i.dom.getBoundingClientRect();let t=Pa(e);return t[t.length-1]||null}function i$(i,e){let t=i.coordsIn(0,1),n=e.coordsIn(0,1);return t&&n&&n.topr&&(e=r);let s=e,o=e,l=0;e==0&&t<0||e==r&&t>=0?_.chrome||_.gecko||(e?(s--,l=1):o=0)?0:a.length-1];return _.safari&&!l&&c.width==0&&(c=Array.prototype.find.call(a,h=>h.width)||c),n==null?c:_a(c,(l?l>0:t<0)==n)}static of(e,t){let n=new i(t||document.createTextNode(e),e);return t||(n.flags|=2),n}},Bn=class i extends fe{constructor(e,t,n,r){super(e,t,r),this.widget=n}isWidget(){return!0}get isHidden(){return this.widget.isHidden}covers(e){return this.flags&48?!1:(this.flags&(e<0?64:128))>0}coordsIn(e,t){return this.coordsInWidget(e,t,!1)}coordsInWidget(e,t,n){let r=this.widget.coordsAt(this.dom,e,t);if(r)return r;if(n)return _a(this.dom.getBoundingClientRect(),this.length?e==0:t<=0);{let s=this.dom.getClientRects(),o=null;if(!s.length)return null;let l=this.flags&16?!0:this.flags&32?!1:e>0;for(let a=l?s.length-1:0;o=s[a],!(e>0?a==0:a==s.length-1||o.top0==n)}},ou=class{constructor(e){this.index=0,this.beforeBreak=!1,this.parents=[],this.tile=e}advance(e,t,n){let{tile:r,index:s,beforeBreak:o,parents:l}=this;for(;e||t>0;)if(r.isComposite())if(o){if(!e)break;n&&n.break(),e--,o=!1}else if(s==r.children.length){if(!e&&!l.length)break;n&&n.leave(r),o=!!r.breakAfter,{tile:r,index:s}=l.pop(),s++}else{let a=r.children[s],c=a.breakAfter;(t>0?a.length<=e:a.length=0;l--){let a=t.marks[l],c=r.lastChild;if(c instanceof ct&&c.mark.eq(a.mark))c.dom!=a.dom&&c.setDOM(Vf(a.dom)),r=c;else{let{dom:h}=a;this.cache.reused.get(a)&&fe.get(a.dom)&&(h=Vf(a.dom));let f=ct.of(a.mark,h);r.append(f),r=f}this.cache.reused.set(a,2)}let s=fe.get(e.text);s&&this.cache.reused.set(s,2);let o=new Dn(e.text,e.text.nodeValue);o.flags|=8,this.pos=e.range.toB,r.append(o)}addInlineWidget(e,t,n){let r=this.afterWidget&&e.flags&48&&(this.afterWidget.flags&48)==(e.flags&48);r||this.flushBuffer();let s=this.ensureMarks(t,n);!r&&!(e.flags&16)&&s.append(this.getBuffer(1)),s.append(e),this.pos+=e.length,this.afterWidget=e}addMark(e,t,n){this.flushBuffer(),this.ensureMarks(t,n).append(e),this.pos+=e.length,this.afterWidget=null}addBlockWidget(e){this.getBlockPos().append(e),this.pos+=e.length,this.lastBlock=e,this.endLine()}continueWidget(e){let t=this.afterWidget||this.lastBlock;t.length+=e,this.pos+=e}addLineStart(e,t){var n;e||(e=h1);let r=Kr.start(e,t||((n=this.cache.find(Kr))===null||n===void 0?void 0:n.dom),!!t);this.getBlockPos().append(this.lastBlock=this.curLine=r)}addLine(e){this.getBlockPos().append(e),this.pos+=e.length,this.lastBlock=e,this.endLine()}addBreak(){this.lastBlock.flags|=1,this.endLine(),this.pos++}addLineStartIfNotCovered(e){this.blockPosCovered()||this.addLineStart(e)}ensureLine(e){this.curLine||this.addLineStart(e)}ensureMarks(e,t){var n;let r=this.curLine;for(let s=e.length-1;s>=0;s--){let o=e[s],l;if(t>0&&(l=r.lastChild)&&l instanceof ct&&l.mark.eq(o))r=l,t--;else{let a=ct.of(o,(n=this.cache.find(ct,c=>c.mark.eq(o)))===null||n===void 0?void 0:n.dom);r.append(a),r=a,t=0}}return r}endLine(){if(this.curLine){this.flushBuffer();let e=this.curLine.lastChild;(!e||!s0(this.curLine,!1)||e.dom.nodeName!="BR"&&e.isWidget()&&!(_.ios&&s0(this.curLine,!0)))&&this.curLine.append(this.cache.findWidget(Yf,0,32)||new Bn(Yf.toDOM(),0,Yf,32)),this.curLine=this.afterWidget=null}}updateBlockWrappers(){this.wrapperPos>this.pos+1e4&&(this.blockWrappers.goto(this.pos),this.wrappers.length=0);for(let e=this.wrappers.length-1;e>=0;e--)this.wrappers[e].to=this.pos){let t=e.rank*102+e.value.rank,n=new lu(e.from,e.to,e.value,t),r=this.wrappers.length;for(;r>0&&(this.wrappers[r-1].rank-n.rank||this.wrappers[r-1].to-n.to)<0;)r--;this.wrappers.splice(r,0,n)}this.wrapperPos=this.pos}getBlockPos(){var e;this.updateBlockWrappers();let t=this.root;for(let n of this.wrappers){let r=t.lastChild;if(n.fromo.wrapper.eq(n.wrapper)))===null||e===void 0?void 0:e.dom);t.append(s),t=s}}return t}blockPosCovered(){let e=this.lastBlock;return e!=null&&!e.breakAfter&&(!e.isWidget()||(e.flags&160)>0)}getBuffer(e){let t=2|(e<0?16:32),n=this.cache.find(Jr,void 0,1);return n&&(n.flags=t),n||new Jr(t)}flushBuffer(){this.afterWidget&&!(this.afterWidget.flags&32)&&(this.afterWidget.parent.append(this.getBuffer(-1)),this.afterWidget=null)}},cu=class{constructor(e){this.skipCount=0,this.text="",this.textOff=0,this.cursor=e.iter()}skip(e){this.textOff+e<=this.text.length?this.textOff+=e:(this.skipCount+=e-(this.text.length-this.textOff),this.text="",this.textOff=0)}next(e){if(this.textOff==this.text.length){let{value:r,lineBreak:s,done:o}=this.cursor.next(this.skipCount);if(this.skipCount=0,o)throw new Error("Ran out of text content when drawing inline views");this.text=r;let l=this.textOff=Math.min(e,r.length);return s?null:r.slice(0,l)}let t=Math.min(this.text.length,this.textOff+e),n=this.text.slice(this.textOff,t);return this.textOff=t,n}},Da=[Bn,Kr,Dn,ct,Jr,wi,Hr];for(let i=0;i[]),this.index=Da.map(()=>0),this.reused=new Map}add(e){let t=e.constructor.bucket,n=this.buckets[t];n.length<6?n.push(e):n[this.index[t]=(this.index[t]+1)%6]=e}find(e,t,n=2){let r=e.bucket,s=this.buckets[r],o=this.index[r];for(let l=0;l{if(this.cache.add(o),o.isComposite())return!1},enter:o=>this.cache.add(o),leave:()=>{},break:()=>{}}}run(e,t){let n=t&&this.getCompositionContext(t.text);for(let r=0,s=0,o=0;;){let l=or){let c=a-r;this.preserve(c,!o,!l),r=a,s+=c}if(!l)break;t&&l.fromA<=t.range.fromA&&l.toA>=t.range.toA?(this.forward(l.fromA,t.range.fromA,t.range.fromA{if(o.isWidget())if(this.openWidget)this.builder.continueWidget(a-l);else{let c=a>0||l{o.isLine()?this.builder.addLineStart(o.attrs,this.cache.maybeReuse(o)):(this.cache.add(o),o instanceof ct&&r.unshift(o.mark)),this.openWidget=!1},leave:o=>{o.isLine()?r.length&&(r.length=s=0):o instanceof ct&&(r.shift(),s=Math.min(s,r.length))},break:()=>{this.builder.addBreak(),this.openWidget=!1}}),this.text.skip(e)}emit(e,t){let n=null,r=this.builder,s=-1,o=G.spans(this.decorations,e,t,{point:(l,a,c,h,f,u)=>{if(c instanceof In){if(this.disallowBlockEffectsFor[u]){if(c.block)throw new RangeError("Block decorations may not be specified via plugins");if(a>this.view.state.doc.lineAt(l).to)throw new RangeError("Decorations that replace line breaks may not be specified via plugins")}if(s=h.length,f>h.length)r.continueWidget(a-l);else{let d=c.widget||(c.block?Vi.block:Vi.inline),p=n$(c),O=this.cache.findWidget(d,a-l,p)||Bn.of(d,this.view,a-l,p);c.block?(c.startSide>0&&r.addLineStartIfNotCovered(n),r.addBlockWidget(O)):(r.ensureLine(n),r.addInlineWidget(O,h,f))}n=null}else n=r$(n,c);a>l&&this.text.skip(a-l)},span:(l,a,c,h)=>{for(let f=l;f-1&&(this.openWidget=o>s),this.openWidget||r.addLineStartIfNotCovered(n),this.openMarks=o}forward(e,t,n=1){t-e<=10?this.old.advance(t-e,n,this.reuseWalker):(this.old.advance(5,-1,this.reuseWalker),this.old.advance(t-e-10,-1),this.old.advance(5,n,this.reuseWalker))}getCompositionContext(e){let t=[],n=null;for(let r=e.parentNode;;r=r.parentNode){let s=fe.get(r);if(r==this.view.contentDOM)break;s instanceof ct?t.push(s):s?.isLine()?n=s:s instanceof wi||(r.nodeName=="DIV"&&!n?n=new Kr(r,h1):n||t.push(ct.of(new go({tagName:r.nodeName.toLowerCase(),attributes:Dv(r)}),r)))}return n?{line:n,marks:t}:null}};function s0(i,e){let t=n=>{for(let r of n.children)if((e?r.isText():r.length)||t(r))return!0;return!1};return t(i)}function n$(i){let e=i.isReplace?(i.startSide<0?64:0)|(i.endSide>0?128:0):i.startSide>0?32:16;return i.block&&(e|=256),e}var h1={class:"cm-line"};function r$(i,e){let t=e.spec.attributes,n=e.spec.class;return!t&&!n||(i||(i={class:"cm-line"}),t&&zu(t,i),n&&(i.class+=" "+n)),i}function s$(i){let e=[];for(let t=i.parents.length;t>1;t--){let n=t==i.parents.length?i.tile:i.parents[t].tile;n instanceof ct&&e.push(n.mark)}return e}function Vf(i){let e=fe.get(i);return e&&e.setDOM(i.cloneNode()),i}var Vi=class extends De{constructor(e){super(),this.tag=e}eq(e){return e.tag==this.tag}toDOM(){return document.createElement(this.tag)}updateDOM(e){return e.nodeName.toLowerCase()==this.tag}get isHidden(){return!0}};Vi.inline=new Vi("span");Vi.block=new Vi("div");var Yf=new class extends De{toDOM(){return document.createElement("br")}get isHidden(){return!0}get editable(){return!0}},ja=class{constructor(e){this.view=e,this.decorations=[],this.blockWrappers=[],this.dynamicDecorationMap=[!1],this.domChanged=null,this.hasComposition=null,this.editContextFormatting=L.none,this.lastCompositionAfterCursor=!1,this.minWidth=0,this.minWidthFrom=0,this.minWidthTo=0,this.impreciseAnchor=null,this.impreciseHead=null,this.forceSelection=!1,this.lastUpdate=Date.now(),this.updateDeco(),this.tile=new Hr(e,e.contentDOM),this.updateInner([new It(0,0,0,e.state.doc.length)],null)}update(e){var t;let n=e.changedRanges;this.minWidth>0&&n.length&&(n.every(({fromA:h,toA:f})=>fthis.minWidthTo)?(this.minWidthFrom=e.changes.mapPos(this.minWidthFrom,1),this.minWidthTo=e.changes.mapPos(this.minWidthTo,1)):this.minWidth=this.minWidthFrom=this.minWidthTo=0),this.updateEditContextFormatting(e);let r=-1;this.view.inputState.composing>=0&&!this.view.observer.editContext&&(!((t=this.domChanged)===null||t===void 0)&&t.newSel?r=this.domChanged.newSel.head:!d$(e.changes,this.hasComposition)&&!e.selectionSet&&(r=e.state.selection.main.head));let s=r>-1?l$(this.view,e.changes,r):null;if(this.domChanged=null,this.hasComposition){let{from:h,to:f}=this.hasComposition;n=new It(h,f,e.changes.mapPos(h,-1),e.changes.mapPos(f,1)).addToSet(n.slice())}this.hasComposition=s?{from:s.range.fromB,to:s.range.toB}:null,(_.ie||_.chrome)&&!s&&e&&e.state.doc.lines!=e.startState.doc.lines&&(this.forceSelection=!0);let o=this.decorations,l=this.blockWrappers;this.updateDeco();let a=h$(o,this.decorations,e.changes);a.length&&(n=It.extendWithRanges(n,a));let c=f$(l,this.blockWrappers,e.changes);return c.length&&(n=It.extendWithRanges(n,c)),s&&!n.some(h=>h.fromA<=s.range.fromA&&h.toA>=s.range.toA)&&(n=s.range.addToSet(n.slice())),this.tile.flags&2&&n.length==0?!1:(this.updateInner(n,s),e.transactions.length&&(this.lastUpdate=Date.now()),!0)}updateInner(e,t){this.view.viewState.mustMeasureContent=!0;let{observer:n}=this.view;n.ignore(()=>{if(t||e.length){let o=this.tile,l=new fu(this.view,o,this.blockWrappers,this.decorations,this.dynamicDecorationMap);t&&fe.get(t.text)&&l.cache.reused.set(fe.get(t.text),2),this.tile=l.run(e,t),uu(o,l.cache.reused)}this.tile.dom.style.height=this.view.viewState.contentHeight/this.view.scaleY+"px",this.tile.dom.style.flexBasis=this.minWidth?this.minWidth+"px":"";let s=_.chrome||_.ios?{node:n.selectionRange.focusNode,written:!1}:void 0;this.tile.sync(s),s&&(s.written||n.selectionRange.focusNode!=s.node||!this.tile.dom.contains(s.node))&&(this.forceSelection=!0),this.tile.dom.style.height=""});let r=[];if(this.view.viewport.from||this.view.viewport.to-1)&&oo(n,this.view.observer.selectionRange)&&!(r&&n.contains(r));if(!(s||t||o))return;let l=this.forceSelection;this.forceSelection=!1;let a=this.view.state.selection.main,c,h;if(a.empty?h=c=this.inlineDOMNearPos(a.anchor,a.assoc||1):(h=this.inlineDOMNearPos(a.head,a.head==a.from?1:-1),c=this.inlineDOMNearPos(a.anchor,a.anchor==a.from?1:-1)),_.gecko&&a.empty&&!this.hasComposition&&o$(c)){let u=document.createTextNode("");this.view.observer.ignore(()=>c.node.insertBefore(u,c.node.childNodes[c.offset]||null)),c=h=new li(u,0),l=!0}let f=this.view.observer.selectionRange;(l||!f.focusNode||(!lo(c.node,c.offset,f.anchorNode,f.anchorOffset)||!lo(h.node,h.offset,f.focusNode,f.focusOffset))&&!this.suppressWidgetCursorChange(f,a))&&(this.view.observer.ignore(()=>{_.android&&_.chrome&&n.contains(f.focusNode)&&u$(f.focusNode,n)&&(n.blur(),n.focus({preventScroll:!0}));let u=bo(this.view.root);if(u)if(a.empty){if(_.gecko){let d=a$(c.node,c.offset);if(d&&d!=3){let p=(d==1?V0:Y0)(c.node,c.offset);p&&(c=new li(p.node,p.offset))}}u.collapse(c.node,c.offset),a.bidiLevel!=null&&u.caretBidiLevel!==void 0&&(u.caretBidiLevel=a.bidiLevel)}else if(u.extend){u.collapse(c.node,c.offset);try{u.extend(h.node,h.offset)}catch{}}else{let d=document.createRange();a.anchor>a.head&&([c,h]=[h,c]),d.setEnd(h.node,h.offset),d.setStart(c.node,c.offset),u.removeAllRanges(),u.addRange(d)}o&&this.view.root.activeElement==n&&(n.blur(),r&&r.focus())}),this.view.observer.setSelectionRange(c,h)),this.impreciseAnchor=c.precise?null:new li(f.anchorNode,f.anchorOffset),this.impreciseHead=h.precise?null:new li(f.focusNode,f.focusOffset)}suppressWidgetCursorChange(e,t){return this.hasComposition&&t.empty&&lo(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset)&&this.posFromDOM(e.focusNode,e.focusOffset)==t.head}enforceCursorAssoc(){if(this.hasComposition)return;let{view:e}=this,t=e.state.selection.main,n=bo(e.root),{anchorNode:r,anchorOffset:s}=e.observer.selectionRange;if(!n||!t.empty||!t.assoc||!n.modify)return;let o=this.lineAt(t.head,t.assoc);if(!o)return;let l=o.posAtStart;if(t.head==l||t.head==l+o.length)return;let a=this.coordsAt(t.head,-1),c=this.coordsAt(t.head,1);if(!a||!c||a.bottom>c.top)return;let h=this.domAtPos(t.head+t.assoc,t.assoc);n.collapse(h.node,h.offset),n.modify("move",t.assoc<0?"forward":"backward","lineboundary"),e.observer.readSelectionRange();let f=e.observer.selectionRange;e.docView.posFromDOM(f.anchorNode,f.anchorOffset)!=t.from&&n.collapse(r,s)}posFromDOM(e,t){let n=this.tile.nearest(e);if(!n)return this.tile.dom.compareDocumentPosition(e)&2?0:this.view.state.doc.length;let r=n.posAtStart;if(n.isComposite()){let s;if(e==n.dom)s=n.dom.childNodes[t];else{let o=ki(e)==0?0:t==0?-1:1;for(;;){let l=e.parentNode;if(l==n.dom)break;o==0&&l.firstChild!=l.lastChild&&(e==l.firstChild?o=-1:o=1),e=l}o<0?s=e:s=e.nextSibling}if(s==n.dom.firstChild)return r;for(;s&&!fe.get(s);)s=s.nextSibling;if(!s)return r+n.length;for(let o=0,l=r;;o++){let a=n.children[o];if(a.dom==s)return l;l+=a.length+a.breakAfter}}else return n.isText()?e==n.dom?r+t:r+(t?n.length:0):r}domAtPos(e,t){let{tile:n,offset:r}=this.tile.resolveBlock(e,t);return n.isWidget()?n.domPosFor(r,t):n.domIn(r,t)}inlineDOMNearPos(e,t){let n,r=-1,s=!1,o,l=-1,a=!1;return this.tile.blockTiles((c,h)=>{if(c.isWidget()){if(c.flags&32&&h>=e)return!0;c.flags&16&&(s=!0)}else{let f=h+c.length;if(h<=e&&(n=c,r=e-h,s=f=e&&!o&&(o=c,l=e-h,a=h>e),h>e&&o)return!0}}),!n&&!o?this.domAtPos(e,t):(s&&o?n=null:a&&n&&(o=null),n&&t<0||!o?n.domIn(r,t):o.domIn(l,t))}coordsAt(e,t,n){let{tile:r,offset:s}=this.tile.resolveBlock(e,t);return r.isWidget()?r.widget instanceof ho?null:r.coordsInWidget(s,t,!0):r.coordsIn(s,t,n)}lineAt(e,t){let{tile:n}=this.tile.resolveBlock(e,t);return n.isLine()?n:null}coordsForChar(e){let{tile:t,offset:n}=this.tile.resolveBlock(e,1);if(!t.isLine())return null;function r(s,o){if(s.isComposite())for(let l of s.children){if(l.length>=o){let a=r(l,o);if(a)return a}if(o-=l.length,o<0)break}else if(s.isText()&&oMath.max(this.view.scrollDOM.clientWidth,this.minWidth)+1,l=-1,a=this.view.textDirection==re.LTR,c=0,h=(f,u,d)=>{for(let p=0;pr);p++){let O=f.children[p],g=u+O.length,b=O.dom.getBoundingClientRect(),{height:w}=b;if(d&&!p&&(c+=b.top-d.top),O instanceof wi)g>n&&h(O,u,b);else if(u>=n&&(c>0&&t.push(-c),t.push(w+c),c=0,o)){let x=O.dom.lastChild,X=x?Pa(x):[];if(X.length){let C=X[X.length-1],T=a?C.right-b.left:b.right-C.left;T>l&&(l=T,this.minWidth=s,this.minWidthFrom=u,this.minWidthTo=g)}}d&&p==f.children.length-1&&(c+=d.bottom-b.bottom),u=g+O.breakAfter}};return h(this.tile,0,null),t}textDirectionAt(e){let{tile:t}=this.tile.resolveBlock(e,1);return getComputedStyle(t.dom).direction=="rtl"?re.RTL:re.LTR}measureTextSize(){let e=this.tile.blockTiles(o=>{if(o.isLine()&&o.children.length&&o.length<=20){let l=0,a;for(let c of o.children){if(!c.isText()||/[^ -~]/.test(c.text))return;let h=Pa(c.dom);if(h.length!=1)return;l+=h[0].width,a=h[0].height}if(l)return{lineHeight:o.dom.getBoundingClientRect().height,charWidth:l/o.length,textHeight:a}}});if(e)return e;let t=document.createElement("div"),n,r,s;return t.className="cm-line",t.style.width="99999px",t.style.position="absolute",t.textContent="abc def ghi jkl mno pqr stu",this.view.observer.ignore(()=>{this.tile.dom.appendChild(t);let o=Pa(t.firstChild)[0];n=t.getBoundingClientRect().height,r=o&&o.width?o.width/27:7,s=o&&o.height?o.height:n,t.remove()}),{lineHeight:n,charWidth:r,textHeight:s}}computeBlockGapDeco(){let e=[],t=this.view.viewState;for(let n=0,r=0;;r++){let s=r==t.viewports.length?null:t.viewports[r],o=s?s.from-1:this.view.state.doc.length;if(o>n){let l=(t.lineBlockAt(o).bottom-t.lineBlockAt(n).top)/this.view.scaleY;e.push(L.replace({widget:new ho(l),block:!0,inclusive:!0,isBlockGap:!0}).range(n,o))}if(!s)break;n=s.to+1}return L.set(e)}updateDeco(){let e=1,t=this.view.state.facet(Ka).map(s=>(this.dynamicDecorationMap[e++]=typeof s=="function")?s(this.view):s),n=!1,r=this.view.state.facet(Hu).map((s,o)=>{let l=typeof s=="function";return l&&(n=!0),l?s(this.view):s});for(r.length&&(this.dynamicDecorationMap[e++]=n,t.push(G.join(r))),this.decorations=[this.editContextFormatting,...t,this.computeBlockGapDeco(),this.view.viewState.lineGapDeco];etypeof s=="function"?s(this.view):s)}scrollIntoView(e){if(e.isSnapshot){let c=this.view.viewState.lineBlockAt(e.range.head);this.view.scrollDOM.scrollTop=c.top-e.yMargin,this.view.scrollDOM.scrollLeft=e.xMargin;return}for(let c of this.view.state.facet(r1))try{if(c(this.view,e.range,e))return!0}catch(h){_e(this.view.state,h,"scroll handler")}let{range:t}=e,n=this.coordsAt(t.head,t.assoc||(t.head>t.anchor?-1:1)),r;if(!n)return;!t.empty&&(r=this.coordsAt(t.anchor,t.anchor>t.head?-1:1))&&(n={left:Math.min(n.left,r.left),top:Math.min(n.top,r.top),right:Math.max(n.right,r.right),bottom:Math.max(n.bottom,r.bottom)});let s=Ku(this.view),o={left:n.left-s.left,top:n.top-s.top,right:n.right+s.right,bottom:n.bottom+s.bottom},{offsetWidth:l,offsetHeight:a}=this.view.scrollDOM;if(Uv(this.view.scrollDOM,o,t.head1&&(n.top>window.visualViewport.offsetTop+window.visualViewport.height||n.bottomn.isWidget()||n.children.some(t);return t(this.tile.resolveBlock(e,1).tile)}destroy(){uu(this.tile)}};function uu(i,e){let t=e?.get(i);if(t!=1){t==null&&i.destroy();for(let n of i.children)uu(n,e)}}function o$(i){return i.node.nodeType==1&&i.node.firstChild&&(i.offset==0||i.node.childNodes[i.offset-1].contentEditable=="false")&&(i.offset==i.node.childNodes.length||i.node.childNodes[i.offset].contentEditable=="false")}function f1(i,e){let t=i.observer.selectionRange;if(!t.focusNode)return null;let n=V0(t.focusNode,t.focusOffset),r=Y0(t.focusNode,t.focusOffset),s=n||r;if(r&&n&&r.node!=n.node){let l=fe.get(r.node);if(!l||l.isText()&&l.text!=r.node.nodeValue)s=r;else if(i.docView.lastCompositionAfterCursor){let a=fe.get(n.node);!a||a.isText()&&a.text!=n.node.nodeValue||(s=r)}}if(i.docView.lastCompositionAfterCursor=s!=n,!s)return null;let o=e-s.offset;return{from:o,to:o+s.node.nodeValue.length,node:s.node}}function l$(i,e,t){let n=f1(i,t);if(!n)return null;let{node:r,from:s,to:o}=n,l=r.nodeValue;if(/[\n\r]/.test(l)||i.state.doc.sliceString(n.from,n.to)!=l)return null;let a=e.invertedDesc;return{range:new It(a.mapPos(s),a.mapPos(o),s,o),text:r}}function a$(i,e){return i.nodeType!=1?0:(e&&i.childNodes[e-1].contentEditable=="false"?1:0)|(e{ne.from&&(t=!0)}),t}var ho=class extends De{constructor(e){super(),this.height=e}toDOM(){let e=document.createElement("div");return e.className="cm-gap",this.updateDOM(e),e}eq(e){return e.height==this.height}updateDOM(e){return e.style.height=this.height+"px",!0}get editable(){return!0}get estimatedHeight(){return this.height}ignoreEvent(){return!1}};function p$(i,e,t=1){let n=i.charCategorizer(e),r=i.doc.lineAt(e),s=e-r.from;if(r.length==0)return v.cursor(e);s==0?t=1:s==r.length&&(t=-1);let o=s,l=s;t<0?o=be(r.text,s,!1):l=be(r.text,s);let a=n(r.text.slice(o,l));for(;o>0;){let c=be(r.text,o,!1);if(n(r.text.slice(c,o))!=a)break;o=c}for(;li.defaultLineHeight*1.5){let l=i.viewState.heightOracle.textHeight,a=Math.floor((r-t.top-(i.defaultLineHeight-l)*.5)/l);s+=a*i.viewState.heightOracle.lineLength}let o=i.state.sliceDoc(t.from,t.to);return t.from+ga(o,s,i.state.tabSize)}function pu(i,e,t){let n=i.lineBlockAt(e);if(Array.isArray(n.type)){let r;for(let s of n.type){if(s.from>e)break;if(!(s.toe)return s;(!r||s.type==Le.Text&&(r.type!=s.type||(t<0?s.frome)))&&(r=s)}}return r||n}return n}function g$(i,e,t,n){let r=pu(i,e.head,e.assoc||-1),s=!n||r.type!=Le.Text||!(i.lineWrapping||r.widgetLineBreaks)?null:i.coordsAtPos(e.assoc<0&&e.head>r.from?e.head-1:e.head);if(s){let o=i.dom.getBoundingClientRect(),l=i.textDirectionAt(r.from),a=i.posAtCoords({x:t==(l==re.LTR)?o.right-1:o.left+1,y:(s.top+s.bottom)/2});if(a!=null)return v.cursor(a,t?-1:1)}return v.cursor(t?r.to:r.from,t?-1:1)}function o0(i,e,t,n){let r=i.state.doc.lineAt(e.head),s=i.bidiSpans(r),o=i.textDirectionAt(r.from);for(let l=e,a=null;;){let c=Hv(r,s,o,l,t),h=G0;if(!c){if(r.number==(t?i.state.doc.lines:1))return l;h=` +`,r=i.state.doc.line(r.number+(t?1:-1)),s=i.bidiSpans(r),c=t?v.cursor(r.from,-1):v.cursor(r.to,1)}if(a){if(!a(h))return l}else{if(!n)return c;a=n(h)}l=c}}function m$(i,e,t){let n=i.state.charCategorizer(e),r=n(t);return s=>{let o=n(s);return r==ae.Space&&(r=o),r==o}}function b$(i,e,t,n){let r=e.head,s=t?1:-1;if(r==(t?i.state.doc.length:0))return v.cursor(r,e.assoc);let o=e.goalColumn,l,a=i.contentDOM.getBoundingClientRect(),c=i.coordsAtPos(r,e.assoc||((e.empty?t:e.head==e.from)?1:-1)),h=i.documentTop;if(c)o==null&&(o=c.left-a.left),l=s<0?c.top:c.bottom;else{let p=i.viewState.lineBlockAt(r);o==null&&(o=Math.min(a.right-a.left,i.defaultCharacterWidth*(r-p.from))),l=(s<0?p.top:p.bottom)+h}let f=a.left+o,u=i.viewState.heightOracle.textHeight>>1,d=n??u;for(let p=0;;p+=u){let O=l+(d+p)*s,g=Ou(i,{x:f,y:O},!1,s);if(t?O>a.bottom:Ol:w{if(e>s&&er(i)),t.from,e.head>t.from?-1:1);return n==t.from?t:v.cursor(n,ni.viewState.docHeight)return new Mt(i.state.doc.length,-1);if(c=i.elementAtHeight(a),n==null)break;if(c.type==Le.Text){if(n<0?c.toi.viewport.to)break;let u=i.docView.coordsAt(n<0?c.from:c.to,n>0?-1:1);if(u&&(n<0?u.top<=a+s:u.bottom>=a+s))break}let f=i.viewState.heightOracle.textHeight/2;a=n>0?c.bottom+f:c.top-f}if(i.viewport.from>=c.to||i.viewport.to<=c.from){if(t)return null;if(c.type==Le.Text){let f=O$(i,r,c,o,l);return new Mt(f,f==c.from?1:-1)}}if(c.type!=Le.Text)return a<(c.top+c.bottom)/2?new Mt(c.from,1):new Mt(c.to,-1);let h=i.docView.lineAt(c.from,2);return(!h||h.length!=c.length)&&(h=i.docView.lineAt(c.from,-2)),new gu(i,o,l,i.textDirectionAt(c.from)).scanTile(h,c.from)}var gu=class{constructor(e,t,n,r){this.view=e,this.x=t,this.y=n,this.baseDir=r,this.line=null,this.spans=null}bidiSpansAt(e){return(!this.line||this.line.from>e||this.line.to1||n.length&&(n[0].level!=this.baseDir||n[0].to+r.from>1;t:if(o.has(O)){for(let w=1;w=s&&(x-=p),!o.has(x)){O=x;break t}}break e}o.add(O);let g=t(O),b=0;if(g)for(let w=0;w1))if(x.bottomthis.y)(!c||c.top>x.top)&&(c=x),b=-1;else{let X=x.left>this.x?this.x-x.left:x.right(p+p+O)/3)return this.y=a.bottom-1,this.scan(e,t,!0);if(c&&c.top<(p+O+O)/3)return this.y=c.top+1,this.scan(e,t,!0)}let d=(l?this.dirAt(e[h],1):this.baseDir)==re.LTR;return{i:h,after:this.x>(u.left+u.right)/2==d}}scanText(e,t){let n=[];for(let s=0;s{let o=n[s]-t,l=n[s+1]-t;return yo(e.dom,o,l).getClientRects()});return r.after?new Mt(n[r.i+1],-1):new Mt(n[r.i],1)}scanTile(e,t){if(!e.length)return new Mt(t,1);if(e.children.length==1){let l=e.children[0];if(l.isText())return this.scanText(l,t);if(l.isComposite())return this.scanTile(l,t)}let n=[t];for(let l=0,a=t;l{let a=e.children[l];return a.flags&48?null:(a.dom.nodeType==1?a.dom:yo(a.dom,0,a.length)).getClientRects()}),s=e.children[r.i],o=n[r.i];return s.isText()?this.scanText(s,o):s.isComposite()?this.scanTile(s,o):r.after?new Mt(n[r.i+1],-1):new Mt(o,1)}},Vr="\uFFFF",mu=class{constructor(e,t){this.points=e,this.view=t,this.text="",this.lineSeparator=t.state.facet(ne.lineSeparator)}append(e){this.text+=e}lineBreak(){this.text+=Vr}readRange(e,t){if(!e)return this;let n=e.parentNode;for(let r=e;;){this.findPointBefore(n,r);let s=this.text.length;this.readNode(r);let o=fe.get(r),l=r.nextSibling;if(l==t){o?.breakAfter&&!l&&n!=this.view.contentDOM&&this.lineBreak();break}let a=fe.get(l);(o&&a?o.breakAfter:(o?o.breakAfter:Za(r))||Za(l)&&(r.nodeName!="BR"||o?.isWidget())&&this.text.length>s)&&!S$(l,t)&&this.lineBreak(),r=l}return this.findPointBefore(n,t),this}readTextNode(e){let t=e.nodeValue;for(let n of this.points)n.node==e&&(n.pos=this.text.length+Math.min(n.offset,t.length));for(let n=0,r=this.lineSeparator?null:/\r\n?|\n/g;;){let s=-1,o=1,l;if(this.lineSeparator?(s=t.indexOf(this.lineSeparator,n),o=this.lineSeparator.length):(l=r.exec(t))&&(s=l.index,o=l[0].length),this.append(t.slice(n,s<0?t.length:s)),s<0)break;if(this.lineBreak(),o>1)for(let a of this.points)a.node==e&&a.pos>this.text.length&&(a.pos-=o-1);n=s+o}}readNode(e){let t=fe.get(e),n=t&&t.overrideDOMText;if(n!=null){this.findPointInside(e,n.length);for(let r=n.iter();!r.next().done;)r.lineBreak?this.lineBreak():this.append(r.value)}else e.nodeType==3?this.readTextNode(e):e.nodeName=="BR"?e.nextSibling&&this.lineBreak():e.nodeType==1&&this.readRange(e.firstChild,null)}findPointBefore(e,t){for(let n of this.points)n.node==e&&e.childNodes[n.offset]==t&&(n.pos=this.text.length)}findPointInside(e,t){for(let n of this.points)(e.nodeType==3?n.node==e:e.contains(n.node))&&(n.pos=this.text.length+(y$(e,n.node,n.offset)?t:0))}};function y$(i,e,t){for(;;){if(!e||t-1;let{impreciseHead:s,impreciseAnchor:o}=e.docView,l=e.state.selection;if(e.state.readOnly&&t>-1)this.newSel=null;else if(t>-1&&(this.bounds=d1(e.docView.tile,t,n,0))){let a=s||o?[]:w$(e),c=new mu(a,e);c.readRange(this.bounds.startDOM,this.bounds.endDOM),this.text=c.text,this.newSel=k$(a,this.bounds.from)}else{let a=e.observer.selectionRange,c=s&&s.node==a.focusNode&&s.offset==a.focusOffset||!eu(e.contentDOM,a.focusNode)?l.main.head:e.docView.posFromDOM(a.focusNode,a.focusOffset),h=o&&o.node==a.anchorNode&&o.offset==a.anchorOffset||!eu(e.contentDOM,a.anchorNode)?l.main.anchor:e.docView.posFromDOM(a.anchorNode,a.anchorOffset),f=e.viewport;if((_.ios||_.chrome)&&c!=h&&Math.min(c,h)<=l.main.from&&Math.max(c,h)>=l.main.to&&(f.from>0||f.to-1&&l.ranges.length>1)this.newSel=l.replaceRange(v.range(h,c));else if(e.lineWrapping&&h==c&&!(l.main.empty&&l.main.head==c)&&e.inputState.lastTouchTime>Date.now()-100){let u=e.coordsAtPos(c,-1),d=0;u&&(d=e.inputState.lastTouchY<=u.bottom?-1:1),this.newSel=v.create([v.cursor(c,d)])}else this.newSel=v.single(h,c)}}};function d1(i,e,t,n){if(i.isComposite()){let r=-1,s=-1,o=-1,l=-1;for(let a=0,c=n,h=n;at)return d1(f,e,t,c);if(u>=e&&r==-1&&(r=a,s=c),c>t&&f.dom.parentNode==i.dom){o=a,l=h;break}h=u,c=u+f.breakAfter}return{from:s,to:l<0?n+i.length:l,startDOM:(r?i.children[r-1].dom.nextSibling:null)||i.dom.firstChild,endDOM:o=0?i.children[o].dom:null}}else return i.isText()?{from:n,to:n+i.length,startDOM:i.dom,endDOM:i.dom.nextSibling}:null}function p1(i,e){let t,{newSel:n}=e,{state:r}=i,s=r.selection.main,o=i.inputState.lastKeyTime>Date.now()-100?i.inputState.lastKeyCode:-1;if(e.bounds){let{from:l,to:a}=e.bounds,c=s.from,h=null;(o===8||_.android&&e.text.length=l&&s.to<=a&&(e.typeOver||f!=e.text)&&f.slice(0,s.from-l)==e.text.slice(0,s.from-l)&&f.slice(s.to-l)==e.text.slice(u=e.text.length-(f.length-(s.to-l)))?t={from:s.from,to:s.to,insert:q.of(e.text.slice(s.from-l,u).split(Vr))}:(d=O1(f,e.text,c-l,h))&&(_.chrome&&o==13&&d.toB==d.from+2&&e.text.slice(d.from,d.toB)==Vr+Vr&&d.toB--,t={from:l+d.from,to:l+d.toA,insert:q.of(e.text.slice(d.from,d.toB).split(Vr))})}else n&&(!i.hasFocus&&r.facet(xi)||Ua(n,s))&&(n=null);if(!t&&!n)return!1;if((_.mac||_.android)&&t&&t.from==t.to&&t.from==s.head-1&&/^\. ?$/.test(t.insert.toString())&&i.contentDOM.getAttribute("autocorrect")=="off"?(n&&t.insert.length==2&&(n=v.single(n.main.anchor-1,n.main.head-1)),t={from:t.from,to:t.to,insert:q.of([t.insert.toString().replace("."," ")])}):r.doc.lineAt(s.from).toDate.now()-50?t={from:s.from,to:s.to,insert:r.toText(i.inputState.insertingText)}:_.chrome&&t&&t.from==t.to&&t.from==s.head&&t.insert.toString()==` + `&&i.lineWrapping&&(n&&(n=v.single(n.main.anchor-1,n.main.head-1)),t={from:s.from,to:s.to,insert:q.of([" "])}),t)return Ju(i,t,n,o);if(n&&!Ua(n,s)){let l=!1,a="select";return i.inputState.lastSelectionTime>Date.now()-50&&(i.inputState.lastSelectionOrigin=="select"&&(l=!0),a=i.inputState.lastSelectionOrigin,a=="select.pointer"&&(n=u1(r.facet(So).map(c=>c(i)),n))),i.dispatch({selection:n,scrollIntoView:l,userEvent:a}),!0}else return!1}function Ju(i,e,t,n=-1){if(_.ios&&i.inputState.flushIOSKey(e))return!0;let r=i.state.selection.main;if(_.android&&(e.to==r.to&&(e.from==r.from||e.from==r.from-1&&i.state.sliceDoc(e.from,r.from)==" ")&&e.insert.length==1&&e.insert.lines==2&&Gr(i.contentDOM,"Enter",13)||(e.from==r.from-1&&e.to==r.to&&e.insert.length==0||n==8&&e.insert.lengthr.head)&&Gr(i.contentDOM,"Backspace",8)||e.from==r.from&&e.to==r.to+1&&e.insert.length==0&&Gr(i.contentDOM,"Delete",46)))return!0;let s=e.insert.toString();i.inputState.composing>=0&&i.inputState.composing++;let o,l=()=>o||(o=x$(i,e,t));return i.state.facet(e1).some(a=>a(i,e.from,e.to,s,l))||i.dispatch(l()),!0}function x$(i,e,t){let n,r=i.state,s=r.selection.main,o=-1;if(e.from==e.to&&e.froms.to){let a=e.fromf(i)),c,a);e.from==h&&(o=h)}if(o>-1)n={changes:e,selection:v.cursor(e.from+e.insert.length,-1)};else if(e.from>=s.from&&e.to<=s.to&&e.to-e.from>=(s.to-s.from)/3&&(!t||t.main.empty&&t.main.from==e.from+e.insert.length)&&i.inputState.composing<0){let a=s.frome.to?r.sliceDoc(e.to,s.to):"";n=r.replaceSelection(i.state.toText(a+e.insert.sliceString(0,void 0,i.state.lineBreak)+c))}else{let a=r.changes(e),c=t&&t.main.to<=a.newLength?t.main:void 0;if(r.selection.ranges.length>1&&(i.inputState.composing>=0||i.inputState.compositionPendingChange)&&e.to<=s.to+10&&e.to>=s.to-10){let h=i.state.sliceDoc(e.from,e.to),f,u=t&&f1(i,t.main.head);if(u){let p=e.insert.length-(e.to-e.from);f={from:u.from,to:u.to-p}}else f=i.state.doc.lineAt(s.head);let d=s.to-e.to;n=r.changeByRange(p=>{if(p.from==s.from&&p.to==s.to)return{changes:a,range:c||p.map(a)};let O=p.to-d,g=O-h.length;if(i.state.sliceDoc(g,O)!=h||O>=f.from&&g<=f.to)return{range:p};let b=r.changes({from:g,to:O,insert:e.insert}),w=p.to-s.to;return{changes:b,range:c?v.range(Math.max(0,c.anchor+w),Math.max(0,c.head+w)):p.map(b)}})}else n={changes:a,selection:c&&r.selection.replaceRange(c)}}let l="input.type";return(i.composing||i.inputState.compositionPendingChange&&i.inputState.compositionEndedAt>Date.now()-50)&&(i.inputState.compositionPendingChange=!1,l+=".compose",i.inputState.compositionFirstChange&&(l+=".start",i.inputState.compositionFirstChange=!1)),r.update(n,{userEvent:l,scrollIntoView:!0})}function O1(i,e,t,n){let r=Math.min(i.length,e.length),s=0;for(;s0&&l>0&&i.charCodeAt(o-1)==e.charCodeAt(l-1);)o--,l--;if(n=="end"){let a=Math.max(0,s-Math.min(o,l));t-=o+a-s}if(o=o?s-t:0;s-=a,l=s+(l-o),o=s}else if(l=l?s-t:0;s-=a,o=s+(o-l),l=s}return{from:s,toA:o,toB:l}}function w$(i){let e=[];if(i.root.activeElement!=i.contentDOM)return e;let{anchorNode:t,anchorOffset:n,focusNode:r,focusOffset:s}=i.observer.selectionRange;return t&&(e.push(new Ia(t,n)),(r!=t||s!=n)&&e.push(new Ia(r,s))),e}function k$(i,e){if(i.length==0)return null;let t=i[0].pos,n=i.length==2?i[1].pos:t;return t<0||n<0?null:t==n?v.create([v.cursor(n+e,-1)]):v.single(t+e,n+e)}function Ua(i,e){return e.head==i.main.head&&e.anchor==i.main.anchor}var yu=class{setSelectionOrigin(e){this.lastSelectionOrigin=e,this.lastSelectionTime=Date.now()}constructor(e){this.view=e,this.lastKeyCode=0,this.lastKeyTime=0,this.touchActive=!1,this.lastTouchTime=0,this.lastTouchX=0,this.lastTouchY=0,this.lastFocusTime=0,this.lastScrollTop=0,this.lastScrollLeft=0,this.lastWheelEvent=0,this.pendingIOSKey=void 0,this.lastIOSMomentumScroll=0,this.tabFocusMode=-1,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastContextMenu=0,this.scrollHandlers=[],this.handlers=Object.create(null),this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.compositionPendingKey=!1,this.compositionPendingChange=!1,this.insertingText="",this.insertingTextAt=0,this.mouseSelection=null,this.draggedContent=null,this.handleEvent=this.handleEvent.bind(this),this.notifiedFocused=e.hasFocus,_.safari&&e.contentDOM.addEventListener("input",()=>null),_.gecko&&D$(e.contentDOM.ownerDocument)}handleEvent(e){!M$(this.view,e)||this.ignoreDuringComposition(e)||e.type=="keydown"&&this.keydown(e)||(this.view.updateState!=0?Promise.resolve().then(()=>this.runHandlers(e.type,e)):this.runHandlers(e.type,e))}runHandlers(e,t){let n=this.handlers[e];if(n){for(let r of n.observers)r(this.view,t);for(let r of n.handlers){if(t.defaultPrevented)break;if(r(this.view,t)){t.preventDefault();break}}}}ensureHandlers(e){let t=v$(e),n=this.handlers,r=this.view.contentDOM;for(let s in t)if(s!="scroll"){let o=!t[s].handlers.length,l=n[s];l&&o!=!l.handlers.length&&(r.removeEventListener(s,this.handleEvent),l=null),l||r.addEventListener(s,this.handleEvent,{passive:o})}for(let s in n)s!="scroll"&&!t[s]&&r.removeEventListener(s,this.handleEvent);this.handlers=t}keydown(e){if(this.lastKeyCode=e.keyCode,this.lastKeyTime=Date.now(),e.keyCode==9&&this.tabFocusMode>-1&&(!this.tabFocusMode||Date.now()<=this.tabFocusMode))return!0;if(this.tabFocusMode>0&&e.keyCode!=27&&m1.indexOf(e.keyCode)<0&&(this.tabFocusMode=-1),_.android&&_.chrome&&!e.synthetic&&(e.keyCode==13||e.keyCode==8))return this.view.observer.delayAndroidKey(e.key,e.keyCode),!0;if(_.ios&&!e.synthetic&&!e.altKey&&!e.metaKey&&(g1.some(t=>t.keyCode==e.keyCode)&&!e.ctrlKey||$$.indexOf(e.key)>-1&&e.ctrlKey)){let t={ctrlKey:e.ctrlKey,altKey:e.altKey,metaKey:e.metaKey,shiftKey:e.shiftKey};t.shiftKey&&_.ios&&!/^(off|none)$/.test(this.view.contentDOM.autocapitalize)&&Q$(this.view.win)&&(t.shiftKey=!1);let n=this.pendingIOSKey={key:e.key,keyCode:e.keyCode,mods:t};return setTimeout(()=>{this.pendingIOSKey==n&&this.flushIOSKey()},50),!0}return e.keyCode!=229&&this.view.observer.forceFlush(),!1}flushIOSKey(e){let t=this.pendingIOSKey;return!t||this.view.observer.pendingRecords().length||t.key=="Enter"&&e&&e.from0?!0:_.safari&&!_.ios&&this.compositionPendingKey&&Date.now()-this.compositionEndedAt<100?(this.compositionPendingKey=!1,!0):!1}startMouseSelection(e){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=e}update(e){this.view.observer.update(e),this.mouseSelection&&this.mouseSelection.update(e),this.draggedContent&&e.docChanged&&(this.draggedContent=this.draggedContent.map(e.changes)),e.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}};function Q$(i){return i.visualViewport?i.visualViewport.height*i.visualViewport.scale/i.document.documentElement.clientHeight<.85:!1}function l0(i,e){return(t,n)=>{try{return e.call(i,n,t)}catch(r){_e(t.state,r)}}}function v$(i){let e=Object.create(null);function t(n){return e[n]||(e[n]={observers:[],handlers:[]})}for(let n of i){let r=n.spec,s=r&&r.plugin.domEventHandlers,o=r&&r.plugin.domEventObservers;if(s)for(let l in s){let a=s[l];a&&t(l).handlers.push(l0(n.value,a))}if(o)for(let l in o){let a=o[l];a&&t(l).observers.push(l0(n.value,a))}}for(let n in Ut)t(n).handlers.push(Ut[n]);for(let n in et)t(n).observers.push(et[n]);return e}var g1=[{key:"Backspace",keyCode:8,inputType:"deleteContentBackward"},{key:"Enter",keyCode:13,inputType:"insertParagraph"},{key:"Enter",keyCode:13,inputType:"insertLineBreak"},{key:"Delete",keyCode:46,inputType:"deleteContentForward"}],$$="dthko",m1=[16,17,18,20,91,92,224,225],ya=6;function Sa(i){return Math.max(0,i)*.7+8}function P$(i,e){return Math.max(Math.abs(i.clientX-e.clientX),Math.abs(i.clientY-e.clientY))}var Su=class{constructor(e,t,n,r){this.view=e,this.startEvent=t,this.style=n,this.mustSelect=r,this.scrollSpeed={x:0,y:0},this.scrolling=-1,this.lastEvent=t,this.scrollParents=D0(e.contentDOM),this.atoms=e.state.facet(So).map(o=>o(e));let s=e.contentDOM.ownerDocument;s.addEventListener("mousemove",this.move=this.move.bind(this)),s.addEventListener("mouseup",this.up=this.up.bind(this)),this.extend=t.shiftKey,this.multiple=e.state.facet(ne.allowMultipleSelections)&&C$(e,t),this.dragging=A$(e,t)&&S1(t)==1?null:!1}start(e){this.dragging===!1&&this.select(e)}move(e){if(e.buttons==0)return this.destroy();if(this.dragging||this.dragging==null&&P$(this.startEvent,e)<10)return;this.select(this.lastEvent=e);let t=0,n=0,r=0,s=0,o=this.view.win.innerWidth,l=this.view.win.innerHeight;this.scrollParents.x&&({left:r,right:o}=this.scrollParents.x.getBoundingClientRect()),this.scrollParents.y&&({top:s,bottom:l}=this.scrollParents.y.getBoundingClientRect());let a=Ku(this.view);e.clientX-a.left<=r+ya?t=-Sa(r-e.clientX):e.clientX+a.right>=o-ya&&(t=Sa(e.clientX-o)),e.clientY-a.top<=s+ya?n=-Sa(s-e.clientY):e.clientY+a.bottom>=l-ya&&(n=Sa(e.clientY-l)),this.setScrollSpeed(t,n)}up(e){this.dragging==null&&this.select(this.lastEvent),this.dragging||e.preventDefault(),this.destroy()}destroy(){this.setScrollSpeed(0,0);let e=this.view.contentDOM.ownerDocument;e.removeEventListener("mousemove",this.move),e.removeEventListener("mouseup",this.up),this.view.inputState.mouseSelection=this.view.inputState.draggedContent=null}setScrollSpeed(e,t){this.scrollSpeed={x:e,y:t},e||t?this.scrolling<0&&(this.scrolling=setInterval(()=>this.scroll(),50)):this.scrolling>-1&&(clearInterval(this.scrolling),this.scrolling=-1)}scroll(){let{x:e,y:t}=this.scrollSpeed;e&&this.scrollParents.x&&(this.scrollParents.x.scrollLeft+=e,e=0),t&&this.scrollParents.y&&(this.scrollParents.y.scrollTop+=t,t=0),(e||t)&&this.view.win.scrollBy(e,t),this.dragging===!1&&this.select(this.lastEvent)}select(e){let{view:t}=this,n=u1(this.atoms,this.style.get(e,this.extend,this.multiple));(this.mustSelect||!n.eq(t.state.selection,this.dragging===!1))&&this.view.dispatch({selection:n,userEvent:"select.pointer"}),this.mustSelect=!1}update(e){e.transactions.some(t=>t.isUserEvent("input.type"))?this.destroy():this.style.update(e)&&setTimeout(()=>this.select(this.lastEvent),20)}};function C$(i,e){let t=i.state.facet(F0);return t.length?t[0](e):_.mac?e.metaKey:e.ctrlKey}function T$(i,e){let t=i.state.facet(H0);return t.length?t[0](e):_.mac?!e.altKey:!e.ctrlKey}function A$(i,e){let{main:t}=i.state.selection;if(t.empty)return!1;let n=bo(i.root);if(!n||n.rangeCount==0)return!0;let r=n.getRangeAt(0).getClientRects();for(let s=0;s=e.clientX&&o.top<=e.clientY&&o.bottom>=e.clientY)return!0}return!1}function M$(i,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target,n;t!=i.contentDOM;t=t.parentNode)if(!t||t.nodeType==11||(n=fe.get(t))&&n.isWidget()&&!n.isHidden&&n.widget.ignoreEvent(e))return!1;return!0}var Ut=Object.create(null),et=Object.create(null),b1=_.ie&&_.ie_version<15||_.ios&&_.webkit_version<604;function R$(i){let e=i.dom.parentNode;if(!e)return;let t=e.appendChild(document.createElement("textarea"));t.style.cssText="position: fixed; left: -10000px; top: 10px",t.focus(),setTimeout(()=>{i.focus(),t.remove(),y1(i,t.value)},50)}function Ja(i,e,t){for(let n of i.facet(e))t=n(t,i);return t}function y1(i,e){e=Ja(i.state,qu,e);let{state:t}=i,n,r=1,s=t.toText(e),o=s.lines==t.selection.ranges.length;if(xu!=null&&t.selection.ranges.every(a=>a.empty)&&xu==s.toString()){let a=-1;n=t.changeByRange(c=>{let h=t.doc.lineAt(c.from);if(h.from==a)return{range:c};a=h.from;let f=t.toText((o?s.line(r++).text:e)+t.lineBreak);return{changes:{from:h.from,insert:f},range:v.cursor(c.from+f.length,-1)}})}else o?n=t.changeByRange(a=>{let c=s.line(r++);return{changes:{from:a.from,to:a.to,insert:c.text},range:v.cursor(a.from+c.length,-1)}}):n=t.replaceSelection(s);i.dispatch(n,{userEvent:"input.paste",scrollIntoView:!0})}et.scroll=i=>{let e=i.inputState;e.lastScrollTop=i.scrollDOM.scrollTop,e.lastScrollLeft=i.scrollDOM.scrollLeft,_.ios&&!e.touchActive&&(e.lastIOSMomentumScroll=Date.now())};et.wheel=et.mousewheel=i=>{i.inputState.lastWheelEvent=Date.now()};Ut.keydown=(i,e)=>(i.inputState.setSelectionOrigin("select"),e.keyCode==27&&i.inputState.tabFocusMode!=0&&(i.inputState.tabFocusMode=Date.now()+2e3),!1);et.touchstart=(i,e)=>{let t=i.inputState,n=e.targetTouches[0];t.touchActive=!0,t.lastTouchTime=Date.now(),n&&(t.lastTouchX=n.clientX,t.lastTouchY=n.clientY),t.setSelectionOrigin("select.pointer")};et.touchmove=i=>{i.inputState.setSelectionOrigin("select.pointer")};et.touchend=(i,e)=>{i.inputState.touchActive=!1};Ut.mousedown=(i,e)=>{if(i.observer.flush(),i.inputState.lastTouchTime>Date.now()-2e3)return!1;let t=null;for(let n of i.state.facet(K0))if(t=n(i,e),t)break;if(!t&&e.button==0&&(t=E$(i,e)),t){let n=!i.hasFocus;i.inputState.startMouseSelection(new Su(i,e,t,n)),n&&i.observer.ignore(()=>{U0(i.contentDOM);let s=i.root.activeElement;s&&!s.contains(i.contentDOM)&&s.blur()});let r=i.inputState.mouseSelection;if(r)return r.start(e),r.dragging===!1}else i.inputState.setSelectionOrigin("select.pointer");return!1};function a0(i,e,t,n){if(n==1)return v.cursor(e,t);if(n==2)return p$(i.state,e,t);{let r=i.docView.lineAt(e,t),s=i.state.doc.lineAt(r?r.posAtEnd:e),o=r?r.posAtStart:s.from,l=r?r.posAtEnd:s.to;return lDate.now()-400&&Math.abs(e.clientX-i.clientX)<2&&Math.abs(e.clientY-i.clientY)<2?(h0+1)%3:1}function E$(i,e){let t=i.posAndSideAtCoords({x:e.clientX,y:e.clientY},!1),n=S1(e),r=i.state.selection;return{update(s){s.docChanged&&(t.pos=s.changes.mapPos(t.pos),r=r.map(s.changes))},get(s,o,l){let a=i.posAndSideAtCoords({x:s.clientX,y:s.clientY},!1),c,h=a0(i,a.pos,a.assoc,n);if(t.pos!=a.pos&&!o){let f=a0(i,t.pos,t.assoc,n),u=Math.min(f.from,h.from),d=Math.max(f.to,h.to);h=u1&&(c=Z$(r,a.pos))?c:l?r.addRange(h):v.create([h])}}}function Z$(i,e){for(let t=0;t=e)return v.create(i.ranges.slice(0,t).concat(i.ranges.slice(t+1)),i.mainIndex==t?0:i.mainIndex-(i.mainIndex>t?1:0))}return null}Ut.dragstart=(i,e)=>{let{selection:{main:t}}=i.state;if(e.target.draggable){let r=i.docView.tile.nearest(e.target);if(r&&r.isWidget()){let s=r.posAtStart,o=s+r.length;(s>=t.to||o<=t.from)&&(t=v.undirectionalRange(s,o))}}let{inputState:n}=i;return n.mouseSelection&&(n.mouseSelection.dragging=!0),n.draggedContent=t,e.dataTransfer&&(e.dataTransfer.setData("Text",Ja(i.state,Gu,i.state.sliceDoc(t.from,t.to))),e.dataTransfer.effectAllowed="copyMove"),!1};Ut.dragend=i=>(i.inputState.draggedContent=null,!1);function u0(i,e,t,n){if(t=Ja(i.state,qu,t),!t)return;let r=i.posAtCoords({x:e.clientX,y:e.clientY},!1),{draggedContent:s}=i.inputState,o=n&&s&&T$(i,e)?{from:s.from,to:s.to}:null,l={from:r,insert:t},a=i.state.changes(o?[o,l]:l);i.focus(),i.dispatch({changes:a,selection:{anchor:a.mapPos(r,-1),head:a.mapPos(r,1)},userEvent:o?"move.drop":"input.drop"}),i.inputState.draggedContent=null}Ut.drop=(i,e)=>{if(!e.dataTransfer)return!1;if(i.state.readOnly)return!0;let t=e.dataTransfer.files;if(t&&t.length){let n=Array(t.length),r=0,s=()=>{++r==t.length&&u0(i,e,n.filter(o=>o!=null).join(i.state.lineBreak),!1)};for(let o=0;o{/[\x00-\x08\x0e-\x1f]{2}/.test(l.result)||(n[o]=l.result),s()},l.readAsText(t[o])}return!0}else{let n=e.dataTransfer.getData("Text");if(n)return u0(i,e,n,!0),!0}return!1};Ut.paste=(i,e)=>{if(i.state.readOnly)return!0;i.observer.flush();let t=b1?null:e.clipboardData;return t?(y1(i,t.getData("text/plain")||t.getData("text/uri-list")),!0):(R$(i),!1)};function _$(i,e){let t=i.dom.parentNode;if(!t)return;let n=t.appendChild(document.createElement("textarea"));n.style.cssText="position: fixed; left: -10000px; top: 10px",n.value=e,n.focus(),n.selectionEnd=e.length,n.selectionStart=0,setTimeout(()=>{n.remove(),i.focus()},50)}function L$(i){let e=[],t=[],n=!1;for(let r of i.selection.ranges)r.empty||(e.push(i.sliceDoc(r.from,r.to)),t.push(r));if(!e.length){let r=-1;for(let{from:s}of i.selection.ranges){let o=i.doc.lineAt(s);o.number>r&&(e.push(o.text),t.push({from:o.from,to:Math.min(i.doc.length,o.to+1)})),r=o.number}n=!0}return{text:Ja(i,Gu,e.join(i.lineBreak)),ranges:t,linewise:n}}var xu=null;Ut.copy=Ut.cut=(i,e)=>{if(!oo(i.contentDOM,i.observer.selectionRange))return!1;let{text:t,ranges:n,linewise:r}=L$(i.state);if(!t&&!r)return!1;xu=r?t:null,e.type=="cut"&&!i.state.readOnly&&i.dispatch({changes:n,scrollIntoView:!0,userEvent:"delete.cut"});let s=b1?null:e.clipboardData;return s?(s.clearData(),s.setData("text/plain",t),!0):(_$(i,t),!1)};var x1=we.define();function w1(i,e){let t=[];for(let n of i.facet(t1)){let r=n(i,e);r&&t.push(r)}return t.length?i.update({effects:t,annotations:x1.of(!0)}):null}function k1(i){setTimeout(()=>{let e=i.hasFocus;if(e!=i.inputState.notifiedFocused){let t=w1(i.state,e);t?i.dispatch(t):i.update([])}},10)}et.focus=i=>{i.inputState.lastFocusTime=Date.now(),!i.scrollDOM.scrollTop&&(i.inputState.lastScrollTop||i.inputState.lastScrollLeft)&&(i.scrollDOM.scrollTop=i.inputState.lastScrollTop,i.scrollDOM.scrollLeft=i.inputState.lastScrollLeft),k1(i)};et.blur=i=>{i.observer.clearSelectionRange(),k1(i)};et.compositionstart=et.compositionupdate=i=>{if(!i.observer.editContext&&(i.inputState.compositionFirstChange==null&&(i.inputState.compositionFirstChange=!0),i.inputState.composing<0)){let{main:e}=i.state.selection;!e.empty&&i.lineBlockAt(e.from).from!=i.lineBlockAt(e.to).from&&i.dispatch({changes:i.state.selection.ranges.filter(t=>!t.empty).map(t=>({from:t.from,to:t.to})),userEvent:"input"}),i.inputState.composing=0}};et.compositionend=i=>{i.observer.editContext||(i.inputState.composing=-1,i.inputState.compositionEndedAt=Date.now(),i.inputState.compositionPendingKey=!0,i.inputState.compositionPendingChange=i.observer.pendingRecords().length>0,i.inputState.compositionFirstChange=null,_.chrome&&_.android?i.observer.flushSoon():i.inputState.compositionPendingChange?Promise.resolve().then(()=>i.observer.flush()):setTimeout(()=>{i.inputState.composing<0&&i.docView.hasComposition&&i.update([])},50))};et.contextmenu=i=>{i.inputState.lastContextMenu=Date.now()};Ut.beforeinput=(i,e)=>{var t,n;if((e.inputType=="insertText"||e.inputType=="insertCompositionText")&&(i.inputState.insertingText=e.data,i.inputState.insertingTextAt=Date.now()),e.inputType=="insertReplacementText"&&i.observer.editContext){let s=(t=e.dataTransfer)===null||t===void 0?void 0:t.getData("text/plain"),o=e.getTargetRanges();if(s&&o.length){let l=o[0],a=i.posAtDOM(l.startContainer,l.startOffset),c=i.posAtDOM(l.endContainer,l.endOffset);return Ju(i,{from:a,to:c,insert:i.state.toText(s)},null),!0}}let r;if(_.chrome&&_.android&&(r=g1.find(s=>s.inputType==e.inputType))&&(i.observer.delayAndroidKey(r.key,r.keyCode),r.key=="Backspace"||r.key=="Delete")){let s=((n=window.visualViewport)===null||n===void 0?void 0:n.height)||0;setTimeout(()=>{var o;(((o=window.visualViewport)===null||o===void 0?void 0:o.height)||0)>s+10&&i.hasFocus&&(i.contentDOM.blur(),i.focus())},100)}return _.ios&&e.inputType=="deleteContentForward"&&i.observer.flushSoon(),_.safari&&e.inputType=="insertText"&&i.inputState.composing>=0&&setTimeout(()=>et.compositionend(i,e),20),!1};var d0=new Set;function D$(i){d0.has(i)||(d0.add(i),i.addEventListener("copy",()=>{}),i.addEventListener("cut",()=>{}))}var p0=["pre-wrap","normal","pre-line","break-spaces"],es=!1;function O0(){es=!1}var wu=class{constructor(e){this.lineWrapping=e,this.doc=q.empty,this.heightSamples={},this.lineHeight=14,this.charWidth=7,this.textHeight=14,this.lineLength=30}heightForGap(e,t){let n=this.doc.lineAt(t).number-this.doc.lineAt(e).number+1;return this.lineWrapping&&(n+=Math.max(0,Math.ceil((t-e-n*this.lineLength*.5)/this.lineLength))),this.lineHeight*n}heightForLine(e){return this.lineWrapping?(1+Math.max(0,Math.ceil((e-this.lineLength)/Math.max(1,this.lineLength-5))))*this.lineHeight:this.lineHeight}setDoc(e){return this.doc=e,this}mustRefreshForWrapping(e){return p0.indexOf(e)>-1!=this.lineWrapping}mustRefreshForHeights(e){let t=!1;for(let n=0;n-1,a=Math.abs(t-this.lineHeight)>.3||this.lineWrapping!=l;if(this.lineWrapping=l,this.lineHeight=t,this.charWidth=n,this.textHeight=r,this.lineLength=s,a){this.heightSamples={};for(let c=0;c0}set outdated(e){this.flags=(e?2:0)|this.flags&-3}setHeight(e){this.height!=e&&(Math.abs(this.height-e)>Ca&&(es=!0),this.height=e)}replace(e,t,n){return i.of(n)}decomposeLeft(e,t){t.push(this)}decomposeRight(e,t){t.push(this)}applyChanges(e,t,n,r){let s=this,o=n.doc;for(let l=r.length-1;l>=0;l--){let{fromA:a,toA:c,fromB:h,toB:f}=r[l],u=s.lineAt(a,he.ByPosNoHeight,n.setDoc(t),0,0),d=u.to>=c?u:s.lineAt(c,he.ByPosNoHeight,n,0,0);for(f+=d.to-c,c=d.to;l>0&&u.from<=r[l-1].toA;)a=r[l-1].fromA,h=r[l-1].fromB,l--,as*2){let l=e[t-1];l.break?e.splice(--t,1,l.left,null,l.right):e.splice(--t,1,l.left,l.right),n+=1+l.break,r-=l.size}else if(s>r*2){let l=e[n];l.break?e.splice(n,1,l.left,null,l.right):e.splice(n,1,l.left,l.right),n+=2+l.break,s-=l.size}else break;else if(r=s&&o(this.lineAt(0,he.ByPos,n,r,s))}setMeasuredHeight(e){let t=e.heights[e.index++];t<0?(this.spaceAbove=-t,t=e.heights[e.index++]):this.spaceAbove=0,this.setHeight(t)}updateHeight(e,t=0,n=!1,r){return r&&r.from<=t&&r.more&&this.setMeasuredHeight(r),this.outdated=!1,this}toString(){return`block(${this.length})`}},At=class i extends Va{constructor(e,t,n){super(e,t,null),this.collapsed=0,this.widgetHeight=0,this.breaks=0,this.spaceAbove=n}mainBlock(e,t){return new jt(t,this.length,e+this.spaceAbove,this.height-this.spaceAbove,this.breaks)}replace(e,t,n){let r=n[0];return n.length==1&&(r instanceof i||r instanceof Ui&&r.flags&4)&&Math.abs(this.length-r.length)<10?(r instanceof Ui?r=new i(r.length,this.height,this.spaceAbove):r.height=this.height,this.outdated||(r.outdated=!1),r):bt.of(n)}updateHeight(e,t=0,n=!1,r){return r&&r.from<=t&&r.more?this.setMeasuredHeight(r):(n||this.outdated)&&(this.spaceAbove=0,this.setHeight(Math.max(this.widgetHeight,e.heightForLine(this.length-this.collapsed))+this.breaks*e.lineHeight)),this.outdated=!1,this}toString(){return`line(${this.length}${this.collapsed?-this.collapsed:""}${this.widgetHeight?":"+this.widgetHeight:""})`}},Ui=class i extends bt{constructor(e){super(e,0)}heightMetrics(e,t){let n=e.doc.lineAt(t).number,r=e.doc.lineAt(t+this.length).number,s=r-n+1,o,l=0;if(e.lineWrapping){let a=Math.min(this.height,e.lineHeight*s);o=a/s,this.length>s+1&&(l=(this.height-a)/(this.length-s-1))}else o=this.height/s;return{firstLine:n,lastLine:r,perLine:o,perChar:l}}blockAt(e,t,n,r){let{firstLine:s,lastLine:o,perLine:l,perChar:a}=this.heightMetrics(t,r);if(t.lineWrapping){let c=r+(e0){let s=n[n.length-1];s instanceof i?n[n.length-1]=new i(s.length+r):n.push(null,new i(r-1))}if(e>0){let s=n[0];s instanceof i?n[0]=new i(e+s.length):n.unshift(new i(e-1),null)}return bt.of(n)}decomposeLeft(e,t){t.push(new i(e-1),null)}decomposeRight(e,t){t.push(null,new i(this.length-e-1))}updateHeight(e,t=0,n=!1,r){let s=t+this.length;if(r&&r.from<=t+this.length&&r.more){let o=[],l=Math.max(t,r.from),a=-1;for(r.from>t&&o.push(new i(r.from-t-1).updateHeight(e,t));l<=s&&r.more;){let h=e.doc.lineAt(l).length;o.length&&o.push(null);let f=r.heights[r.index++],u=0;f<0&&(u=-f,f=r.heights[r.index++]),a==-1?a=f:Math.abs(f-a)>=Ca&&(a=-2);let d=new At(h,f,u);d.outdated=!1,o.push(d),l+=h+1}l<=s&&o.push(null,new i(s-l).updateHeight(e,l));let c=bt.of(o);return(a<0||Math.abs(c.height-this.height)>=Ca||Math.abs(a-this.heightMetrics(e,t).perLine)>=Ca)&&(es=!0),Ba(this,c)}else(n||this.outdated)&&(this.setHeight(e.heightForGap(t,t+this.length)),this.outdated=!1);return this}toString(){return`gap(${this.length})`}},Qu=class extends bt{constructor(e,t,n){super(e.length+(t?1:0)+n.length,e.height+n.height,(t?1:0)|(e.outdated||n.outdated?2:0)),this.left=e,this.right=n,this.size=e.size+n.size}get break(){return this.flags&1}blockAt(e,t,n,r){let s=n+this.left.height;return el))return c;let h=t==he.ByPosNoHeight?he.ByPosNoHeight:he.ByPos;return a?c.join(this.right.lineAt(l,h,n,o,l)):this.left.lineAt(l,h,n,r,s).join(c)}forEachLine(e,t,n,r,s,o){let l=r+this.left.height,a=s+this.left.length+this.break;if(this.break)e=a&&this.right.forEachLine(e,t,n,l,a,o);else{let c=this.lineAt(a,he.ByPos,n,r,s);e=e&&c.from<=t&&o(c),t>c.to&&this.right.forEachLine(Math.max(e,c.to+1),t,n,l,a,o)}}replace(e,t,n){let r=this.left.length+this.break;if(tthis.left.length)return this.balanced(this.left,this.right.replace(e-r,t-r,n));let s=[];e>0&&this.decomposeLeft(e,s);let o=s.length;for(let l of n)s.push(l);if(e>0&&g0(s,o-1),t=n&&t.push(null)),e>n&&this.right.decomposeLeft(e-n,t)}decomposeRight(e,t){let n=this.left.length,r=n+this.break;if(e>=r)return this.right.decomposeRight(e-r,t);e2*t.size||t.size>2*e.size?bt.of(this.break?[e,null,t]:[e,t]):(this.left=Ba(this.left,e),this.right=Ba(this.right,t),this.setHeight(e.height+t.height),this.outdated=e.outdated||t.outdated,this.size=e.size+t.size,this.length=e.length+this.break+t.length,this)}updateHeight(e,t=0,n=!1,r){let{left:s,right:o}=this,l=t+s.length+this.break,a=null;return r&&r.from<=t+s.length&&r.more?a=s=s.updateHeight(e,t,n,r):s.updateHeight(e,t,n),r&&r.from<=l+o.length&&r.more?a=o=o.updateHeight(e,l,n,r):o.updateHeight(e,l,n),a?this.balanced(s,o):(this.height=this.left.height+this.right.height,this.outdated=!1,this)}toString(){return this.left+(this.break?" ":"-")+this.right}};function g0(i,e){let t,n;i[e]==null&&(t=i[e-1])instanceof Ui&&(n=i[e+1])instanceof Ui&&i.splice(e-1,3,new Ui(t.length+1+n.length))}var I$=5,vu=class i{constructor(e,t){this.pos=e,this.oracle=t,this.nodes=[],this.lineStart=-1,this.lineEnd=-1,this.covering=null,this.writtenTo=e}get isCovered(){return this.covering&&this.nodes[this.nodes.length-1]==this.covering}span(e,t){if(this.lineStart>-1){let n=Math.min(t,this.lineEnd),r=this.nodes[this.nodes.length-1];r instanceof At?r.length+=n-this.pos:(n>this.pos||!this.isCovered)&&this.nodes.push(new At(n-this.pos,-1,0)),this.writtenTo=n,t>n&&(this.nodes.push(null),this.writtenTo++,this.lineStart=-1)}this.pos=t}point(e,t,n){if(e=I$)&&this.addLineDeco(r,s,o)}else t>e&&this.span(e,t);this.lineEnd>-1&&this.lineEnd-1)return;let{from:e,to:t}=this.oracle.doc.lineAt(this.pos);this.lineStart=e,this.lineEnd=t,this.writtenToe&&this.nodes.push(new At(this.pos-e,-1,0)),this.writtenTo=this.pos}blankContent(e,t){let n=new Ui(t-e);return this.oracle.doc.lineAt(e).to==t&&(n.flags|=4),n}ensureLine(){this.enterLine();let e=this.nodes.length?this.nodes[this.nodes.length-1]:null;if(e instanceof At)return e;let t=new At(0,-1,0);return this.nodes.push(t),t}addBlock(e){this.enterLine();let t=e.deco;t&&t.startSide>0&&!this.isCovered&&this.ensureLine(),this.nodes.push(e),this.writtenTo=this.pos=this.pos+e.length,t&&t.endSide>0&&(this.covering=e)}addLineDeco(e,t,n){let r=this.ensureLine();r.length+=n,r.collapsed+=n,r.widgetHeight=Math.max(r.widgetHeight,e),r.breaks+=t,this.writtenTo=this.pos=this.pos+n}finish(e){let t=this.nodes.length==0?null:this.nodes[this.nodes.length-1];this.lineStart>-1&&!(t instanceof At)&&!this.isCovered?this.nodes.push(new At(0,-1,0)):(this.writtenToh.clientHeight||h.scrollWidth>h.clientWidth)&&f.overflow!="visible"){let u=h.getBoundingClientRect();s=Math.max(s,u.left),o=Math.min(o,u.right),l=Math.max(l,u.top),a=Math.min(c==i.parentNode?r.innerHeight:a,u.bottom)}c=f.position=="absolute"||f.position=="fixed"?h.offsetParent:h.parentNode}else if(c.nodeType==11)c=c.host;else break;return{left:s-t.left,right:Math.max(s,o)-t.left,top:l-(t.top+e),bottom:Math.max(l,a)-(t.top+e)}}function V$(i){let e=i.getBoundingClientRect(),t=i.ownerDocument.defaultView||window;return e.left0&&e.top0}function Y$(i,e){let t=i.getBoundingClientRect();return{left:0,right:t.right-t.left,top:e,bottom:t.bottom-(t.top+e)}}var uo=class{constructor(e,t,n,r){this.from=e,this.to=t,this.size=n,this.displaySize=r}static same(e,t){if(e.length!=t.length)return!1;for(let n=0;ntypeof r!="function"&&r.class=="cm-lineWrapping");this.heightOracle=new wu(n),this.stateDeco=b0(t),this.heightMap=bt.empty().applyChanges(this.stateDeco,q.empty,this.heightOracle.setDoc(t.doc),[new It(0,0,0,t.doc.length)]);for(let r=0;r<2&&(this.viewport=this.getViewport(0,null),!!this.updateForViewport());r++);this.updateViewportLines(),this.lineGaps=this.ensureLineGaps([]),this.lineGapDeco=L.set(this.lineGaps.map(r=>r.draw(this,!1))),this.scrollParent=e.scrollDOM,this.computeVisibleRanges()}updateForViewport(){let e=[this.viewport],{main:t}=this.state.selection;for(let n=0;n<=1;n++){let r=n?t.head:t.anchor;if(!e.some(({from:s,to:o})=>r>=s&&r<=o)){let{from:s,to:o}=this.lineBlockAt(r);e.push(new zr(s,o))}}return this.viewports=e.sort((n,r)=>n.from-r.from),this.updateScaler()}updateScaler(){let e=this.scaler;return this.scaler=this.heightMap.height<=7e6?m0:new Cu(this.heightOracle,this.heightMap,this.viewports),e.eq(this.scaler)?0:2}updateViewportLines(){this.viewportLines=[],this.heightMap.forEachLine(this.viewport.from,this.viewport.to,this.heightOracle.setDoc(this.state.doc),0,0,e=>{this.viewportLines.push(ro(e,this.scaler))})}update(e,t=null){this.state=e.state;let n=this.stateDeco;this.stateDeco=b0(this.state);let r=e.changedRanges,s=It.extendWithRanges(r,U$(n,this.stateDeco,e?e.changes:He.empty(this.state.doc.length))),o=this.heightMap.height,l=this.scrolledToBottom?null:this.scrollAnchorAt(this.scrollOffset);O0(),this.heightMap=this.heightMap.applyChanges(this.stateDeco,e.startState.doc,this.heightOracle.setDoc(this.state.doc),s),(this.heightMap.height!=o||es)&&(e.flags|=2),l?(this.scrollAnchorPos=e.changes.mapPos(l.from,-1),this.scrollAnchorHeight=l.top):(this.scrollAnchorPos=-1,this.scrollAnchorHeight=o);let a=s.length?this.mapViewport(this.viewport,e.changes):this.viewport;(t&&(t.range.heada.to)||!this.viewportIsAppropriate(a))&&(a=this.getViewport(0,t));let c=a.from!=this.viewport.from||a.to!=this.viewport.to;this.viewport=a,e.flags|=this.updateForViewport(),(c||!e.changes.empty||e.flags&2)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps,e.changes))),e.flags|=this.computeVisibleRanges(e.changes),t&&(this.scrollTarget=t),!this.mustEnforceCursorAssoc&&(e.selectionSet||e.focusChanged)&&e.view.lineWrapping&&e.state.selection.main.empty&&e.state.selection.main.assoc&&!e.state.facet(n1)&&(this.mustEnforceCursorAssoc=!0)}measure(){let{view:e}=this,t=e.contentDOM,n=window.getComputedStyle(t),r=this.heightOracle,s=n.whiteSpace;this.defaultTextDirection=n.direction=="rtl"?re.RTL:re.LTR;let o=this.heightOracle.mustRefreshForWrapping(s)||this.mustMeasureContent==="refresh",l=t.getBoundingClientRect(),a=o||this.mustMeasureContent||this.contentDOMHeight!=l.height;this.contentDOMHeight=l.height,this.mustMeasureContent=!1;let c=0,h=0;if(l.width&&l.height){let{scaleX:C,scaleY:T}=L0(t,l);(C>.005&&Math.abs(this.scaleX-C)>.005||T>.005&&Math.abs(this.scaleY-T)>.005)&&(this.scaleX=C,this.scaleY=T,c|=16,o=a=!0)}let f=(parseInt(n.paddingTop)||0)*this.scaleY,u=(parseInt(n.paddingBottom)||0)*this.scaleY;(this.paddingTop!=f||this.paddingBottom!=u)&&(this.paddingTop=f,this.paddingBottom=u,c|=18),this.editorWidth!=e.scrollDOM.clientWidth&&(r.lineWrapping&&(a=!0),this.editorWidth=e.scrollDOM.clientWidth,c|=16);let d=D0(this.view.contentDOM,!1).y;d!=this.scrollParent&&(this.scrollParent=d,this.scrollAnchorHeight=-1,this.scrollOffset=0);let p=this.getScrollOffset();this.scrollOffset!=p&&(this.scrollAnchorHeight=-1,this.scrollOffset=p),this.scrolledToBottom=B0(this.scrollParent||e.win);let O=(this.printing?Y$:B$)(t,this.paddingTop),g=O.top-this.pixelViewport.top,b=O.bottom-this.pixelViewport.bottom;this.pixelViewport=O;let w=this.pixelViewport.bottom>this.pixelViewport.top&&this.pixelViewport.right>this.pixelViewport.left;if(w!=this.inView&&(this.inView=w,w&&(a=!0)),!this.inView&&!this.scrollTarget&&!V$(e.dom))return 0;let x=l.width;if((this.contentDOMWidth!=x||this.editorHeight!=e.scrollDOM.clientHeight)&&(this.contentDOMWidth=l.width,this.editorHeight=e.scrollDOM.clientHeight,c|=16),a){let C=e.docView.measureVisibleLineHeights(this.viewport);if(r.mustRefreshForHeights(C)&&(o=!0),o||r.lineWrapping&&Math.abs(x-this.contentDOMWidth)>r.charWidth){let{lineHeight:T,charWidth:y,textHeight:Q}=e.docView.measureTextSize();o=T>0&&r.refresh(s,T,y,Q,Math.max(5,x/y),C),o&&(e.docView.minWidth=0,c|=16)}g>0&&b>0?h=Math.max(g,b):g<0&&b<0&&(h=Math.min(g,b)),O0();for(let T of this.viewports){let y=T.from==this.viewport.from?C:e.docView.measureVisibleLineHeights(T);this.heightMap=(o?bt.empty().applyChanges(this.stateDeco,q.empty,this.heightOracle,[new It(0,0,0,e.state.doc.length)]):this.heightMap).updateHeight(r,0,o,new ku(T.from,y))}es&&(c|=2)}let X=!this.viewportIsAppropriate(this.viewport,h)||this.scrollTarget&&(this.scrollTarget.range.headthis.viewport.to);return X&&(c&2&&(c|=this.updateScaler()),this.viewport=this.getViewport(h,this.scrollTarget),c|=this.updateForViewport()),(c&2||X)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(o?[]:this.lineGaps,e)),c|=this.computeVisibleRanges(),this.mustEnforceCursorAssoc&&(this.mustEnforceCursorAssoc=!1,e.docView.enforceCursorAssoc()),c}get visibleTop(){return this.scaler.fromDOM(this.pixelViewport.top)}get visibleBottom(){return this.scaler.fromDOM(this.pixelViewport.bottom)}getViewport(e,t){let n=.5-Math.max(-.5,Math.min(.5,e/1e3/2)),r=this.heightMap,s=this.heightOracle,{visibleTop:o,visibleBottom:l}=this,a=new zr(r.lineAt(o-n*1e3,he.ByHeight,s,0,0).from,r.lineAt(l+(1-n)*1e3,he.ByHeight,s,0,0).to);if(t){let{head:c}=t.range;if(ca.to){let h=Math.min(this.editorHeight,this.pixelViewport.bottom-this.pixelViewport.top),f=r.lineAt(c,he.ByPos,s,0,0),u;t.y=="center"?u=(f.top+f.bottom)/2-h/2:t.y=="start"||t.y=="nearest"&&c=l+Math.max(10,Math.min(n,250)))&&r>o-2*1e3&&s>1,o=r<<1;if(this.defaultTextDirection!=re.LTR&&!n)return[];let l=[],a=(h,f,u,d)=>{if(f-hh&&bb.from>=u.from&&b.to<=u.to&&Math.abs(b.from-h)b.fromw));if(!g){if(fx.from<=f&&x.to>=f)){let x=t.moveToLineBoundary(v.cursor(f),!1,!0).head;x>h&&(f=x)}let b=this.gapSize(u,h,f,d),w=n||b<2e6?b:2e6;g=new uo(h,f,b,w)}l.push(g)},c=h=>{if(h.length2e6)for(let T of e)T.from>=h.from&&T.fromh.from&&a(h.from,d,h,f),pt.draw(this,this.heightOracle.lineWrapping))))}computeVisibleRanges(e){let t=this.stateDeco;this.lineGaps.length&&(t=t.concat(this.lineGapDeco));let n=[];G.spans(t,this.viewport.from,this.viewport.to,{span(s,o){n.push({from:s,to:o})},point(){}},20);let r=0;if(n.length!=this.visibleRanges.length)r=12;else for(let s=0;s=this.viewport.from&&e<=this.viewport.to&&this.viewportLines.find(t=>t.from<=e&&t.to>=e)||ro(this.heightMap.lineAt(e,he.ByPos,this.heightOracle,0,0),this.scaler)}lineBlockAtHeight(e){return e>=this.viewportLines[0].top&&e<=this.viewportLines[this.viewportLines.length-1].bottom&&this.viewportLines.find(t=>t.top<=e&&t.bottom>=e)||ro(this.heightMap.lineAt(this.scaler.fromDOM(e),he.ByHeight,this.heightOracle,0,0),this.scaler)}getScrollOffset(){return this.scrollParent==this.view.scrollDOM?this.scrollParent.scrollTop*this.scaleY:(this.scrollParent?this.scrollParent.getBoundingClientRect().top:0)-this.view.contentDOM.getBoundingClientRect().top}scrollAnchorAt(e){let t=this.lineBlockAtHeight(e+8);return t.from>=this.viewport.from||this.viewportLines[0].top-e>200?t:this.viewportLines[0]}elementAtHeight(e){return ro(this.heightMap.blockAt(this.scaler.fromDOM(e),this.heightOracle,0,0),this.scaler)}get docHeight(){return this.scaler.toDOM(this.heightMap.height)}get contentHeight(){return this.docHeight+this.paddingTop+this.paddingBottom}},zr=class{constructor(e,t){this.from=e,this.to=t}};function z$(i,e,t){let n=[],r=i,s=0;return G.spans(t,i,e,{span(){},point(o,l){o>r&&(n.push({from:r,to:o}),s+=o-r),r=l}},20),r=1)return e[e.length-1].to;let n=Math.floor(i*t);for(let r=0;;r++){let{from:s,to:o}=e[r],l=o-s;if(n<=l)return s+n;n-=l}}function wa(i,e){let t=0;for(let{from:n,to:r}of i.ranges){if(e<=r){t+=e-n;break}t+=r-n}return t/i.total}function W$(i,e){for(let t of i)if(e(t))return t}var m0={toDOM(i){return i},fromDOM(i){return i},scale:1,eq(i){return i==this}};function b0(i){let e=i.facet(Ka).filter(n=>typeof n!="function"),t=i.facet(Hu).filter(n=>typeof n!="function");return t.length&&e.push(G.join(t)),e}var Cu=class i{constructor(e,t,n){let r=0,s=0,o=0;this.viewports=n.map(({from:l,to:a})=>{let c=t.lineAt(l,he.ByPos,e,0,0).top,h=t.lineAt(a,he.ByPos,e,0,0).bottom;return r+=h-c,{from:l,to:a,top:c,bottom:h,domTop:0,domBottom:0}}),this.scale=(7e6-r)/(t.height-r);for(let l of this.viewports)l.domTop=o+(l.top-s)*this.scale,o=l.domBottom=l.domTop+(l.bottom-l.top),s=l.bottom}toDOM(e){for(let t=0,n=0,r=0;;t++){let s=tt.from==e.viewports[n].from&&t.to==e.viewports[n].to):!1}};function ro(i,e){if(e.scale==1)return i;let t=e.toDOM(i.top),n=e.toDOM(i.bottom);return new jt(i.from,i.length,t,n-t,Array.isArray(i._content)?i._content.map(r=>ro(r,e)):i._content)}var ka=R.define({combine:i=>i.join(" ")}),Tu=R.define({combine:i=>i.indexOf(!0)>-1}),Au=Tt.newName(),Q1=Tt.newName(),v1=Tt.newName(),$1={"&light":"."+Q1,"&dark":"."+v1};function Mu(i,e,t){return new Tt(e,{finish(n){return/&/.test(n)?n.replace(/&\w*/,r=>{if(r=="&")return i;if(!t||!t[r])throw new RangeError(`Unsupported selector: ${r}`);return t[r]}):i+" "+n}})}var N$=Mu("."+Au,{"&":{position:"relative !important",boxSizing:"border-box","&.cm-focused":{outline:"1px dotted #212121"},display:"flex !important",flexDirection:"column"},".cm-scroller":{display:"flex !important",alignItems:"flex-start !important",fontFamily:"monospace",lineHeight:1.4,height:"100%",overflowX:"auto",position:"relative",zIndex:0,overflowAnchor:"none"},".cm-content":{margin:0,flexGrow:2,flexShrink:0,display:"block",whiteSpace:"pre",wordWrap:"normal",boxSizing:"border-box",minHeight:"100%",padding:"4px 0",outline:"none","&[contenteditable=true]":{WebkitUserModify:"read-write-plaintext-only"}},".cm-lineWrapping":{whiteSpace_fallback:"pre-wrap",whiteSpace:"break-spaces",wordBreak:"break-word",overflowWrap:"anywhere",flexShrink:1},"&light .cm-content":{caretColor:"black"},"&dark .cm-content":{caretColor:"white"},".cm-line":{display:"block",padding:"0 2px 0 6px"},".cm-layer":{userSelect:"none",position:"absolute",left:0,top:0,contain:"size style","& > *":{position:"absolute"}},"&light .cm-selectionBackground":{background:"#d9d9d9"},"&dark .cm-selectionBackground":{background:"#222"},"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#d7d4f0"},"&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#233"},".cm-cursorLayer":{pointerEvents:"none"},"&.cm-focused > .cm-scroller > .cm-cursorLayer":{animation:"steps(1) cm-blink 1.2s infinite"},"@keyframes cm-blink":{"0%":{},"50%":{opacity:0},"100%":{}},"@keyframes cm-blink2":{"0%":{},"50%":{opacity:0},"100%":{}},".cm-cursor, .cm-dropCursor":{borderLeft:"1.2px solid black",marginLeft:"-0.6px",pointerEvents:"none"},".cm-cursor":{display:"none"},"&dark .cm-cursor":{borderLeftColor:"#ddd"},".cm-selectionHandle":{backgroundColor:"currentColor",width:"1.5px"},".cm-selectionHandle-start::before, .cm-selectionHandle-end::before":{content:'""',backgroundColor:"inherit",borderRadius:"50%",width:"8px",height:"8px",position:"absolute",left:"-3.25px"},".cm-selectionHandle-start::before":{top:"-8px"},".cm-selectionHandle-end::before":{bottom:"-8px"},".cm-dropCursor":{position:"absolute"},"&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor":{display:"block"},".cm-iso":{unicodeBidi:"isolate"},".cm-announced":{position:"fixed",top:"-10000px"},"@media print":{".cm-announced":{display:"none"}},"&light .cm-activeLine":{backgroundColor:"#cceeff44"},"&dark .cm-activeLine":{backgroundColor:"#99eeff33"},"&light .cm-specialChar":{color:"red"},"&dark .cm-specialChar":{color:"#f78"},".cm-gutters":{flexShrink:0,display:"flex",height:"100%",boxSizing:"border-box",zIndex:200},".cm-gutters-before":{insetInlineStart:0},".cm-gutters-after":{insetInlineEnd:0},"&light .cm-gutters":{backgroundColor:"#f5f5f5",color:"#6c6c6c",border:"0px solid #ddd","&.cm-gutters-before":{borderRightWidth:"1px"},"&.cm-gutters-after":{borderLeftWidth:"1px"}},"&dark .cm-gutters":{backgroundColor:"#333338",color:"#ccc"},".cm-gutter":{display:"flex !important",flexDirection:"column",flexShrink:0,boxSizing:"border-box",minHeight:"100%",overflow:"hidden"},".cm-gutterElement":{boxSizing:"border-box"},".cm-lineNumbers .cm-gutterElement":{padding:"0 3px 0 5px",minWidth:"20px",textAlign:"right",whiteSpace:"nowrap"},"&light .cm-activeLineGutter":{backgroundColor:"#e2f2ff"},"&dark .cm-activeLineGutter":{backgroundColor:"#222227"},".cm-panels":{boxSizing:"border-box",position:"sticky",left:0,right:0,zIndex:300},"&light .cm-panels":{backgroundColor:"#f5f5f5",color:"black"},".cm-panels-top":{top:"0"},".cm-panels-bottom":{bottom:"0"},"&light .cm-panels-top":{borderBottom:"1px solid #ddd"},"&light .cm-panels-bottom":{borderTop:"1px solid #ddd"},"&dark .cm-panels":{backgroundColor:"#333338",color:"white"},".cm-dialog":{padding:"2px 19px 4px 6px",position:"relative","& label":{fontSize:"80%"}},".cm-dialog-close":{position:"absolute",top:"3px",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",fontSize:"14px",padding:"0"},".cm-tab":{display:"inline-block",overflow:"hidden",verticalAlign:"bottom"},".cm-widgetBuffer":{verticalAlign:"text-top",height:"1em",width:0,display:"inline"},".cm-placeholder":{color:"#888",display:"inline-block",verticalAlign:"top",userSelect:"none"},".cm-highlightSpace":{background:"radial-gradient(circle at 50% 55%, #aaa 20%, transparent 0) no-repeat",backgroundSize:".4em",backgroundPosition:"calc(min(50%, 0px)) center"},".cm-highlightTab":{backgroundImage:`url('data:image/svg+xml,')`,backgroundSize:"auto 100%",backgroundPosition:"right 90%",backgroundRepeat:"no-repeat"},".cm-trailingSpace":{backgroundColor:"#ff332255"},".cm-button":{verticalAlign:"middle",color:"inherit",fontSize:"70%",padding:".2em 1em",borderRadius:"1px"},"&light .cm-button":{backgroundImage:"linear-gradient(#eff1f5, #d9d9df)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#b4b4b4, #d0d3d6)"}},"&dark .cm-button":{backgroundImage:"linear-gradient(#393939, #111)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#111, #333)"}},".cm-textfield":{verticalAlign:"middle",color:"inherit",fontSize:"70%",border:"1px solid silver",padding:".2em .5em"},"&light .cm-textfield":{backgroundColor:"white"},"&dark .cm-textfield":{border:"1px solid #555",backgroundColor:"inherit"}},$1),q$={childList:!0,characterData:!0,subtree:!0,attributes:!0,characterDataOldValue:!0},Wf=_.ie&&_.ie_version<=11,Ru=class{constructor(e){this.view=e,this.active=!1,this.editContext=null,this.selectionRange=new tu,this.selectionChanged=!1,this.delayedFlush=-1,this.resizeTimeout=-1,this.queue=[],this.delayedAndroidKey=null,this.flushingAndroidKey=-1,this.lastChange=0,this.scrollTargets=[],this.intersection=null,this.resizeScroll=null,this.intersecting=!1,this.gapIntersection=null,this.gaps=[],this.printQuery=null,this.parentCheck=-1,this.dom=e.contentDOM,this.observer=new MutationObserver(t=>{for(let n of t)this.queue.push(n);(_.ie&&_.ie_version<=11||_.ios&&e.composing)&&t.some(n=>n.type=="childList"&&n.removedNodes.length||n.type=="characterData"&&n.oldValue.length>n.target.nodeValue.length)?this.flushSoon():this.flush()}),window.EditContext&&_.android&&e.constructor.EDIT_CONTEXT!==!1&&!(_.chrome&&_.chrome_version<126)&&(this.editContext=new Xu(e),e.state.facet(xi)&&(e.contentDOM.editContext=this.editContext.editContext)),Wf&&(this.onCharData=t=>{this.queue.push({target:t.target,type:"characterData",oldValue:t.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this),this.onResize=this.onResize.bind(this),this.onPrint=this.onPrint.bind(this),this.onScroll=this.onScroll.bind(this),window.matchMedia&&(this.printQuery=window.matchMedia("print")),typeof ResizeObserver=="function"&&(this.resizeScroll=new ResizeObserver(()=>{var t;((t=this.view.docView)===null||t===void 0?void 0:t.lastUpdate){this.parentCheck<0&&(this.parentCheck=setTimeout(this.listenForScroll.bind(this),1e3)),t.length>0&&t[t.length-1].intersectionRatio>0!=this.intersecting&&(this.intersecting=!this.intersecting,this.intersecting!=this.view.inView&&this.onScrollChanged(document.createEvent("Event")))},{threshold:[0,.001]}),this.intersection.observe(this.dom),this.gapIntersection=new IntersectionObserver(t=>{t.length>0&&t[t.length-1].intersectionRatio>0&&this.onScrollChanged(document.createEvent("Event"))},{})),this.listenForScroll(),this.readSelectionRange()}onScrollChanged(e){this.view.inputState.runHandlers("scroll",e),this.intersecting&&this.view.measure()}onScroll(e){this.intersecting&&this.flush(!1),this.editContext&&this.view.requestMeasure(this.editContext.measureReq),this.onScrollChanged(e)}onResize(){this.resizeTimeout<0&&(this.resizeTimeout=setTimeout(()=>{this.resizeTimeout=-1,this.view.requestMeasure()},50))}onPrint(e){(e.type=="change"||!e.type)&&!e.matches||(this.view.viewState.printing=!0,this.view.measure(),setTimeout(()=>{this.view.viewState.printing=!1,this.view.requestMeasure()},500))}updateGaps(e){if(this.gapIntersection&&(e.length!=this.gaps.length||this.gaps.some((t,n)=>t!=e[n]))){this.gapIntersection.disconnect();for(let t of e)this.gapIntersection.observe(t);this.gaps=e}}onSelectionChange(e){let t=this.selectionChanged;if(!this.readSelectionRange()||this.delayedAndroidKey)return;let{view:n}=this,r=this.selectionRange;if(n.state.facet(xi)?n.root.activeElement!=this.dom:!oo(this.dom,r))return;let s=r.anchorNode&&n.docView.tile.nearest(r.anchorNode);if(s&&s.isWidget()&&s.widget.ignoreEvent(e)){t||(this.selectionChanged=!1);return}(_.ie&&_.ie_version<=11||_.android&&_.chrome)&&!n.state.selection.main.empty&&r.focusNode&&lo(r.focusNode,r.focusOffset,r.anchorNode,r.anchorOffset)?this.flushSoon():this.flush(!1)}readSelectionRange(){let{view:e}=this,t=bo(e.root);if(!t)return!1;let n=_.safari&&e.root.nodeType==11&&e.root.activeElement==this.dom&&G$(this.view,t)||t;if(!n||this.selectionRange.eq(n))return!1;let r=oo(this.dom,n);return r&&!this.selectionChanged&&e.inputState.lastFocusTime>Date.now()-200&&e.inputState.lastTouchTime{let s=this.delayedAndroidKey;s&&(this.clearDelayedAndroidKey(),this.view.inputState.lastKeyCode=s.keyCode,this.view.inputState.lastKeyTime=Date.now(),!this.flush()&&s.force&&Gr(this.dom,s.key,s.keyCode))};this.flushingAndroidKey=this.view.win.requestAnimationFrame(r)}(!this.delayedAndroidKey||e=="Enter")&&(this.delayedAndroidKey={key:e,keyCode:t,force:this.lastChange{this.delayedFlush=-1,this.flush()}))}forceFlush(){this.delayedFlush>=0&&(this.view.win.cancelAnimationFrame(this.delayedFlush),this.delayedFlush=-1),this.flush()}pendingRecords(){for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}processRecords(){let e=this.pendingRecords();e.length&&(this.queue=[]);let t=-1,n=-1,r=!1;for(let s of e){let o=this.readMutation(s);o&&(o.typeOver&&(r=!0),t==-1?{from:t,to:n}=o:(t=Math.min(o.from,t),n=Math.max(o.to,n)))}return{from:t,to:n,typeOver:r}}readChange(){let{from:e,to:t,typeOver:n}=this.processRecords(),r=this.selectionChanged&&oo(this.dom,this.selectionRange);if(e<0&&!r)return null;e>-1&&(this.lastChange=Date.now()),this.view.inputState.lastFocusTime=0,this.selectionChanged=!1;let s=new bu(this.view,e,t,n);return this.view.docView.domChanged={newSel:s.newSel?s.newSel.main:null},s}flush(e=!0){if(this.delayedFlush>=0||this.delayedAndroidKey)return!1;e&&this.readSelectionRange();let t=this.readChange();if(!t)return this.view.requestMeasure(),!1;let n=this.view.state,r=p1(this.view,t);return this.view.state==n&&(t.domChanged||t.newSel&&!Ua(this.view.state.selection,t.newSel.main))&&this.view.update([]),r}readMutation(e){let t=this.view.docView.tile.nearest(e.target);if(!t||t.isWidget())return null;if(t.markDirty(e.type=="attributes"),e.type=="childList"){let n=y0(t,e.previousSibling||e.target.previousSibling,-1),r=y0(t,e.nextSibling||e.target.nextSibling,1);return{from:n?t.posAfter(n):t.posAtStart,to:r?t.posBefore(r):t.posAtEnd,typeOver:!1}}else return e.type=="characterData"?{from:t.posAtStart,to:t.posAtEnd,typeOver:e.target.nodeValue==e.oldValue}:null}setWindow(e){e!=this.win&&(this.removeWindowListeners(this.win),this.win=e,this.addWindowListeners(this.win))}addWindowListeners(e){e.addEventListener("resize",this.onResize),this.printQuery?this.printQuery.addEventListener?this.printQuery.addEventListener("change",this.onPrint):this.printQuery.addListener(this.onPrint):e.addEventListener("beforeprint",this.onPrint),e.addEventListener("scroll",this.onScroll),e.document.addEventListener("selectionchange",this.onSelectionChange)}removeWindowListeners(e){e.removeEventListener("scroll",this.onScroll),e.removeEventListener("resize",this.onResize),this.printQuery?this.printQuery.removeEventListener?this.printQuery.removeEventListener("change",this.onPrint):this.printQuery.removeListener(this.onPrint):e.removeEventListener("beforeprint",this.onPrint),e.document.removeEventListener("selectionchange",this.onSelectionChange)}update(e){this.editContext&&(this.editContext.update(e),e.startState.facet(xi)!=e.state.facet(xi)&&(e.view.contentDOM.editContext=e.state.facet(xi)?this.editContext.editContext:null))}destroy(){var e,t,n;this.stop(),(e=this.intersection)===null||e===void 0||e.disconnect(),(t=this.gapIntersection)===null||t===void 0||t.disconnect(),(n=this.resizeScroll)===null||n===void 0||n.disconnect();for(let r of this.scrollTargets)r.removeEventListener("scroll",this.onScroll);this.removeWindowListeners(this.win),clearTimeout(this.parentCheck),clearTimeout(this.resizeTimeout),this.win.cancelAnimationFrame(this.delayedFlush),this.win.cancelAnimationFrame(this.flushingAndroidKey),this.editContext&&(this.view.contentDOM.editContext=null,this.editContext.destroy())}};function y0(i,e,t){for(;e;){let n=fe.get(e);if(n&&n.parent==i)return n;let r=e.parentNode;e=r!=i.dom?r:t>0?e.nextSibling:e.previousSibling}return null}function S0(i,e){let t=e.startContainer,n=e.startOffset,r=e.endContainer,s=e.endOffset,o=i.docView.domAtPos(i.state.selection.main.anchor,1);return lo(o.node,o.offset,r,s)&&([t,n,r,s]=[r,s,t,n]),{anchorNode:t,anchorOffset:n,focusNode:r,focusOffset:s}}function G$(i,e){if(e.getComposedRanges){let r=e.getComposedRanges(i.root)[0];if(r)return S0(i,r)}let t=null;function n(r){r.preventDefault(),r.stopImmediatePropagation(),t=r.getTargetRanges()[0]}return i.contentDOM.addEventListener("beforeinput",n,!0),i.dom.ownerDocument.execCommand("indent"),i.contentDOM.removeEventListener("beforeinput",n,!0),t?S0(i,t):null}var Xu=class{constructor(e){this.from=0,this.to=0,this.pendingContextChange=null,this.handlers=Object.create(null),this.composing=null,this.resetRange(e.state);let t=this.editContext=new window.EditContext({text:e.state.doc.sliceString(this.from,this.to),selectionStart:this.toContextPos(Math.max(this.from,Math.min(this.to,e.state.selection.main.anchor))),selectionEnd:this.toContextPos(e.state.selection.main.head)});this.handlers.textupdate=n=>{let r=e.state.selection.main,{anchor:s,head:o}=r,l=this.toEditorPos(n.updateRangeStart),a=this.toEditorPos(n.updateRangeEnd);e.inputState.composing>=0&&!this.composing&&(this.composing={contextBase:n.updateRangeStart,editorBase:l,drifted:!1});let c=a-l>n.text.length;l==this.from&&sthis.to&&(a=s);let h=O1(e.state.sliceDoc(l,a),n.text,(c?r.from:r.to)-l,c?"end":null);if(!h){let u=v.single(this.toEditorPos(n.selectionStart),this.toEditorPos(n.selectionEnd));Ua(u,r)||e.dispatch({selection:u,userEvent:"select"});return}let f={from:h.from+l,to:h.toA+l,insert:q.of(n.text.slice(h.from,h.toB).split(` +`))};if((_.mac||_.android)&&f.from==o-1&&/^\. ?$/.test(n.text)&&e.contentDOM.getAttribute("autocorrect")=="off"&&(f={from:l,to:a,insert:q.of([n.text.replace("."," ")])}),this.pendingContextChange=f,!e.state.readOnly){let u=this.to-this.from+(f.to-f.from+f.insert.length);Ju(e,f,v.single(this.toEditorPos(n.selectionStart,u),this.toEditorPos(n.selectionEnd,u)))}this.pendingContextChange&&(this.revertPending(e.state),this.setSelection(e.state)),f.from=0&&!/[\\p{Alphabetic}\\p{Number}_]/.test(t.text.slice(Math.max(0,n.updateRangeStart-1),Math.min(t.text.length,n.updateRangeStart+1)))&&this.handlers.compositionend(n)},this.handlers.characterboundsupdate=n=>{let r=[],s=null;for(let o=this.toEditorPos(n.rangeStart),l=this.toEditorPos(n.rangeEnd);o{let r=[];for(let s of n.getTextFormats()){let o=s.underlineStyle,l=s.underlineThickness;if(!/none/i.test(o)&&!/none/i.test(l)){let a=this.toEditorPos(s.rangeStart),c=this.toEditorPos(s.rangeEnd);if(a{e.inputState.composing<0&&(e.inputState.composing=0,e.inputState.compositionFirstChange=!0)},this.handlers.compositionend=()=>{if(e.inputState.composing=-1,e.inputState.compositionFirstChange=null,this.composing){let{drifted:n}=this.composing;this.composing=null,n&&this.reset(e.state)}};for(let n in this.handlers)t.addEventListener(n,this.handlers[n]);this.measureReq={read:n=>{let r=bo(n.root);r&&r.rangeCount&&this.editContext.updateSelectionBounds(r.getRangeAt(0).getBoundingClientRect())}}}applyEdits(e){let t=0,n=!1,r=this.pendingContextChange;return e.changes.iterChanges((s,o,l,a,c)=>{if(n)return;let h=c.length-(o-s);if(r&&o>=r.to)if(r.from==s&&r.to==o&&r.insert.eq(c)){r=this.pendingContextChange=null,t+=h,this.to+=h;return}else r=null,this.revertPending(e.state);if(s+=t,o+=t,o<=this.from)this.from+=h,this.to+=h;else if(sthis.to||this.to-this.from+c.length>3e4){n=!0;return}this.editContext.updateText(this.toContextPos(s),this.toContextPos(o),c.toString()),this.to+=h}t+=h}),r&&!n&&this.revertPending(e.state),!n}update(e){let t=this.pendingContextChange,n=e.startState.selection.main;this.composing&&(this.composing.drifted||!e.changes.touchesRange(n.from,n.to)&&e.transactions.some(r=>!r.isUserEvent("input.type")&&r.changes.touchesRange(this.from,this.to)))?(this.composing.drifted=!0,this.composing.editorBase=e.changes.mapPos(this.composing.editorBase)):!this.applyEdits(e)||!this.rangeIsValid(e.state)?(this.pendingContextChange=null,this.reset(e.state)):(e.docChanged||e.selectionSet||t)&&this.setSelection(e.state),(e.geometryChanged||e.docChanged||e.selectionSet)&&e.view.requestMeasure(this.measureReq)}resetRange(e){let{head:t}=e.selection.main;this.from=Math.max(0,t-1e4),this.to=Math.min(e.doc.length,t+1e4)}reset(e){this.resetRange(e),this.editContext.updateText(0,this.editContext.text.length,e.doc.sliceString(this.from,this.to)),this.setSelection(e)}revertPending(e){let t=this.pendingContextChange;this.pendingContextChange=null,this.editContext.updateText(this.toContextPos(t.from),this.toContextPos(t.from+t.insert.length),e.doc.sliceString(t.from,t.to))}setSelection(e){let{main:t}=e.selection,n=this.toContextPos(Math.max(this.from,Math.min(this.to,t.anchor))),r=this.toContextPos(t.head);(this.editContext.selectionStart!=n||this.editContext.selectionEnd!=r)&&this.editContext.updateSelection(n,r)}rangeIsValid(e){let{head:t}=e.selection.main;return!(this.from>0&&t-this.from<500||this.to1e4*3)}toEditorPos(e,t=this.to-this.from){e=Math.min(e,t);let n=this.composing;return n&&n.drifted?n.editorBase+(e-n.contextBase):e+this.from}toContextPos(e){let t=this.composing;return t&&t.drifted?t.contextBase+(e-t.editorBase):e-this.from}destroy(){for(let e in this.handlers)this.editContext.removeEventListener(e,this.handlers[e])}},M=class i{get state(){return this.viewState.state}get viewport(){return this.viewState.viewport}get visibleRanges(){return this.viewState.visibleRanges}get inView(){return this.viewState.inView}get composing(){return!!this.inputState&&this.inputState.composing>0}get compositionStarted(){return!!this.inputState&&this.inputState.composing>=0}get root(){return this._root}get win(){return this.dom.ownerDocument.defaultView||window}constructor(e={}){var t;this.plugins=[],this.pluginMap=new Map,this.editorAttrs={},this.contentAttrs={},this.bidiCache=[],this.destroyed=!1,this.updateState=2,this.measureScheduled=-1,this.measureRequests=[],this.clearAnnouncement=-1,this.contentDOM=document.createElement("div"),this.scrollDOM=document.createElement("div"),this.scrollDOM.tabIndex=-1,this.scrollDOM.className="cm-scroller",this.scrollDOM.appendChild(this.contentDOM),this.announceDOM=document.createElement("div"),this.announceDOM.className="cm-announced",this.announceDOM.setAttribute("aria-live","polite"),this.dom=document.createElement("div"),this.dom.appendChild(this.announceDOM),this.dom.appendChild(this.scrollDOM),e.parent&&e.parent.appendChild(this.dom);let{dispatch:n}=e;this.dispatchTransactions=e.dispatchTransactions||n&&(r=>r.forEach(s=>n(s,this)))||(r=>this.update(r)),this.dispatch=this.dispatch.bind(this),this._root=e.root||Bv(e.parent)||document,this.viewState=new Ya(this,e.state||ne.create(e)),e.scrollTo&&e.scrollTo.is(ba)&&(this.viewState.scrollTarget=e.scrollTo.value.clip(this.viewState.state)),this.plugins=this.state.facet(Yr).map(r=>new co(r));for(let r of this.plugins)r.update(this);this.observer=new Ru(this),this.inputState=new yu(this),this.inputState.ensureHandlers(this.plugins),this.docView=new ja(this),this.mountStyles(),this.updateAttrs(),this.updateState=0,this.requestMeasure(),!((t=document.fonts)===null||t===void 0)&&t.ready&&document.fonts.ready.then(()=>{this.viewState.mustMeasureContent="refresh",this.requestMeasure()})}dispatch(...e){let t=e.length==1&&e[0]instanceof xe?e:e.length==1&&Array.isArray(e[0])?e[0]:[this.state.update(...e)];this.dispatchTransactions(t,this)}update(e){if(this.updateState!=0)throw new Error("Calls to EditorView.update are not allowed while an update is in progress");let t=!1,n=!1,r,s=this.state;for(let u of e){if(u.startState!=s)throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state.");s=u.state}if(this.destroyed){this.viewState.state=s;return}let o=this.hasFocus,l=0,a=null;e.some(u=>u.annotation(x1))?(this.inputState.notifiedFocused=o,l=1):o!=this.inputState.notifiedFocused&&(this.inputState.notifiedFocused=o,a=w1(s,o),a||(l=1));let c=this.observer.delayedAndroidKey,h=null;if(c?(this.observer.clearDelayedAndroidKey(),h=this.observer.readChange(),(h&&!this.state.doc.eq(s.doc)||!this.state.selection.eq(s.selection))&&(h=null)):this.observer.clear(),s.facet(ne.phrases)!=this.state.facet(ne.phrases))return this.setState(s);r=La.create(this,s,e),r.flags|=l;let f=this.viewState.scrollTarget;try{this.updateState=2;for(let u of e){if(f&&(f=f.map(u.changes)),u.scrollIntoView){let{main:d}=u.state.selection,{x:p,y:O}=this.state.facet(i.cursorScrollMargin);f=new ao(d.empty?d:v.cursor(d.head,d.head>d.anchor?-1:1),"nearest","nearest",O,p)}for(let d of u.effects)d.is(ba)&&(f=d.value.clip(this.state))}this.viewState.update(r,f),this.bidiCache=za.update(this.bidiCache,r.changes),r.empty||(this.updatePlugins(r),this.inputState.update(r)),t=this.docView.update(r),this.state.facet(no)!=this.styleModules&&this.mountStyles(),n=this.updateAttrs(),this.showAnnouncements(e),this.docView.updateSelection(t,e.some(u=>u.isUserEvent("select.pointer")))}finally{this.updateState=0}if(r.startState.facet(ka)!=r.state.facet(ka)&&(this.viewState.mustMeasureContent=!0),(t||n||f||this.viewState.mustEnforceCursorAssoc||this.viewState.mustMeasureContent)&&this.requestMeasure(),t&&this.docViewUpdate(),!r.empty)for(let u of this.state.facet(su))try{u(r)}catch(d){_e(this.state,d,"update listener")}(a||h)&&Promise.resolve().then(()=>{a&&this.state==a.startState&&this.dispatch(a),h&&!p1(this,h)&&c.force&&Gr(this.contentDOM,c.key,c.keyCode)})}setState(e){if(this.updateState!=0)throw new Error("Calls to EditorView.setState are not allowed while an update is in progress");if(this.destroyed){this.viewState.state=e;return}this.updateState=2;let t=this.hasFocus;try{for(let n of this.plugins)n.destroy(this);this.viewState=new Ya(this,e),this.plugins=e.facet(Yr).map(n=>new co(n)),this.pluginMap.clear();for(let n of this.plugins)n.update(this);this.docView.destroy(),this.docView=new ja(this),this.inputState.ensureHandlers(this.plugins),this.mountStyles(),this.updateAttrs(),this.bidiCache=[]}finally{this.updateState=0}t&&this.focus(),this.requestMeasure()}updatePlugins(e){let t=e.startState.facet(Yr),n=e.state.facet(Yr);if(t!=n){let r=[];for(let s of n){let o=t.indexOf(s);if(o<0)r.push(new co(s));else{let l=this.plugins[o];l.mustUpdate=e,r.push(l)}}for(let s of this.plugins)s.mustUpdate!=e&&s.destroy(this);this.plugins=r,this.pluginMap.clear()}else for(let r of this.plugins)r.mustUpdate=e;for(let r=0;r-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.observer.delayedAndroidKey){this.measureScheduled=-1,this.requestMeasure();return}this.measureScheduled=0,e&&this.observer.forceFlush();let t=null,n=this.viewState.scrollParent,r=this.viewState.getScrollOffset(),{scrollAnchorPos:s,scrollAnchorHeight:o,scaleY:l}=this.viewState;Math.abs(r-this.viewState.scrollOffset)>1&&(o=-1),this.viewState.scrollAnchorHeight=-1;try{for(let a=0;;a++){if(o<0){if(B0(n||this.win))s=-1,o=this.viewState.heightMap.height/this.viewState.scaleY;else{let p=this.viewState.scrollAnchorAt(r);s=p.from,o=p.top}l=this.viewState.scaleY}this.updateState=1;let c=this.viewState.measure();if(!c&&!this.measureRequests.length&&this.viewState.scrollTarget==null)break;if(a>5){console.warn(this.measureRequests.length?"Measure loop restarted more than 5 times":"Viewport failed to stabilize");break}let h=[];c&4||([this.measureRequests,h]=[h,this.measureRequests]);let f=h.map(p=>{try{return p.read(this)}catch(O){return _e(this.state,O),x0}}),u=La.create(this,this.state,[]),d=!1;u.flags|=c,t?t.flags|=c:t=u,this.updateState=2,u.empty||(this.updatePlugins(u),this.inputState.update(u),this.updateAttrs(),d=this.docView.update(u),d&&this.docViewUpdate());for(let p=0;p1||O<-1)&&!(_.ios&&this.inputState.lastIOSMomentumScroll>Date.now()-100)&&(n==this.scrollDOM||this.hasFocus||Math.max(this.inputState.lastWheelEvent,this.inputState.lastTouchTime)>Date.now()-100)){r=r+O,n?s<0?n.scrollTop=n.scrollHeight:n.scrollTop+=O:this.win.scrollBy(0,O),o=-1;continue}}break}}}finally{this.updateState=0,this.measureScheduled=-1}if(t&&!t.empty)for(let a of this.state.facet(su))a(t)}get themeClasses(){return Au+" "+(this.state.facet(Tu)?v1:Q1)+" "+this.state.facet(ka)}updateAttrs(){let e=w0(this,o1,{class:"cm-editor"+(this.hasFocus?" cm-focused ":" ")+this.themeClasses}),t={spellcheck:"false",autocorrect:"off",autocapitalize:"off",writingsuggestions:"false",translate:"no",contenteditable:this.state.facet(xi)?"true":"false",class:"cm-content",style:`${_.tabSize}: ${this.state.tabSize}`,role:"textbox","aria-multiline":"true"};this.state.readOnly&&(t["aria-readonly"]="true"),w0(this,Fu,t);let n=this.observer.ignore(()=>{let r=e0(this.contentDOM,this.contentAttrs,t),s=e0(this.dom,this.editorAttrs,e);return r||s});return this.editorAttrs=e,this.contentAttrs=t,n}showAnnouncements(e){let t=!0;for(let n of e)for(let r of n.effects)if(r.is(i.announce)){t&&(this.announceDOM.textContent="",this.win.clearTimeout(this.clearAnnouncement),this.clearAnnouncement=this.win.setTimeout(()=>{this.announceDOM.textContent="\xA0"},200),t=!1);let s=this.announceDOM.appendChild(document.createElement("div"));s.textContent=r.value}}mountStyles(){this.styleModules=this.state.facet(no);let e=this.state.facet(i.cspNonce);Tt.mount(this.root,this.styleModules.concat(N$).reverse(),e?{nonce:e}:void 0)}readMeasured(){if(this.updateState==2)throw new Error("Reading the editor layout isn't allowed during an update");this.updateState==0&&this.measureScheduled>-1&&this.measure(!1)}requestMeasure(e){if(this.measureScheduled<0&&(this.measureScheduled=this.win.requestAnimationFrame(()=>this.measure())),e){if(this.measureRequests.indexOf(e)>-1)return;if(e.key!=null){for(let t=0;tn.plugin==e)||null),t&&t.update(this).value}get documentTop(){return this.contentDOM.getBoundingClientRect().top+this.viewState.paddingTop}get documentPadding(){return{top:this.viewState.paddingTop,bottom:this.viewState.paddingBottom}}get scaleX(){return this.viewState.scaleX}get scaleY(){return this.viewState.scaleY}elementAtHeight(e){return this.readMeasured(),this.viewState.elementAtHeight(e)}lineBlockAtHeight(e){return this.readMeasured(),this.viewState.lineBlockAtHeight(e)}get viewportLineBlocks(){return this.viewState.viewportLines}lineBlockAt(e){return this.viewState.lineBlockAt(e)}get contentHeight(){return this.viewState.contentHeight}moveByChar(e,t,n){return zf(this,e,o0(this,e,t,n))}moveByGroup(e,t){return zf(this,e,o0(this,e,t,n=>m$(this,e.head,n)))}visualLineSide(e,t){return t?v.cursor(e.to,1):v.cursor(e.from,-1)}moveToLineBoundary(e,t,n=!0){return g$(this,e,t,n)}moveVertically(e,t,n){return zf(this,e,b$(this,e,t,n))}domAtPos(e,t=1){return this.docView.domAtPos(e,t)}posAtDOM(e,t=0){return this.docView.posFromDOM(e,t)}posAtCoords(e,t=!0){this.readMeasured();let n=Ou(this,e,t);return n&&n.pos}posAndSideAtCoords(e,t=!0){return this.readMeasured(),Ou(this,e,t)}coordsAtPos(e,t=1){this.readMeasured();let n=this.state.doc.lineAt(e),r=this.bidiSpans(n),s=r[Rt.find(r,e-n.from,-1,t)];return n.length&&(e==n.from&&t<0||e==n.to&&t>0)&&s.dir!=this.textDirectionAt(n.from)&&(e==n.to?(e=n.from+s.from,t=1):(e=n.from+s.to,t=-1)),this.docView.coordsAt(e,t,s.dir==re.RTL)}coordsForChar(e){return this.readMeasured(),this.docView.coordsForChar(e)}get defaultCharacterWidth(){return this.viewState.heightOracle.charWidth}get defaultLineHeight(){return this.viewState.heightOracle.lineHeight}get textDirection(){return this.viewState.defaultTextDirection}textDirectionAt(e){return!this.state.facet(i1)||ethis.viewport.to?this.textDirection:(this.readMeasured(),this.docView.textDirectionAt(e))}get lineWrapping(){return this.viewState.heightOracle.lineWrapping}bidiSpans(e){if(e.length>F$)return q0(e.length);let t=this.textDirectionAt(e.from),n;for(let s of this.bidiCache)if(s.from==e.from&&s.dir==t&&(s.fresh||N0(s.isolates,n=n0(this,e))))return s.order;n||(n=n0(this,e));let r=Fv(e.text,t,n);return this.bidiCache.push(new za(e.from,e.to,t,n,!0,r)),r}get hasFocus(){var e;return(this.dom.ownerDocument.hasFocus()||_.safari&&((e=this.inputState)===null||e===void 0?void 0:e.lastContextMenu)>Date.now()-3e4)&&this.root.activeElement==this.contentDOM}focus(){this.observer.ignore(()=>{U0(this.contentDOM),this.docView.updateSelection()})}setRoot(e){this._root!=e&&(this._root=e,this.observer.setWindow((e.nodeType==9?e:e.ownerDocument).defaultView||window),this.mountStyles())}destroy(){this.root.activeElement==this.contentDOM&&this.contentDOM.blur();for(let e of this.plugins)e.destroy(this);this.plugins=[],this.inputState.destroy(),this.docView.destroy(),this.dom.remove(),this.observer.destroy(),this.win.clearTimeout(this.clearAnnouncement),this.measureScheduled>-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.destroyed=!0}static scrollIntoView(e,t={}){var n,r,s,o;return ba.of(new ao(typeof e=="number"?v.cursor(e):e,(n=t.y)!==null&&n!==void 0?n:"nearest",(r=t.x)!==null&&r!==void 0?r:"nearest",(s=t.yMargin)!==null&&s!==void 0?s:5,(o=t.xMargin)!==null&&o!==void 0?o:5))}scrollSnapshot(){let{scrollTop:e,scrollLeft:t}=this.scrollDOM,n=this.viewState.scrollAnchorAt(e);return ba.of(new ao(v.cursor(n.from),"start","start",n.top-e,t,!0))}setTabFocusMode(e){e==null?this.inputState.tabFocusMode=this.inputState.tabFocusMode<0?0:-1:typeof e=="boolean"?this.inputState.tabFocusMode=e?0:-1:this.inputState.tabFocusMode!=0&&(this.inputState.tabFocusMode=Date.now()+e)}static domEventHandlers(e){return se.define(()=>({}),{eventHandlers:e})}static domEventObservers(e){return se.define(()=>({}),{eventObservers:e})}static theme(e,t){let n=Tt.newName(),r=[ka.of(n),no.of(Mu(`.${n}`,e))];return t&&t.dark&&r.push(Tu.of(!0)),r}static baseTheme(e){return at.lowest(no.of(Mu("."+Au,e,$1)))}static findFromDOM(e){var t;let n=e.querySelector(".cm-content"),r=n&&fe.get(n)||fe.get(e);return((t=r?.root)===null||t===void 0?void 0:t.view)||null}};M.styleModule=no;M.inputHandler=e1;M.clipboardInputFilter=qu;M.clipboardOutputFilter=Gu;M.scrollHandler=r1;M.focusChangeEffect=t1;M.perLineTextDirection=i1;M.exceptionSink=J0;M.updateListener=su;M.editable=xi;M.mouseSelectionStyle=K0;M.dragMovesSelection=H0;M.clickAddsSelectionRange=F0;M.decorations=Ka;M.blockWrappers=l1;M.outerDecorations=Hu;M.atomicRanges=So;M.bidiIsolatedRanges=a1;M.cursorScrollMargin=R.define({combine:i=>{let e=5,t=5;for(let n of i)typeof n=="number"?e=t=n:{x:e,y:t}=n;return{x:e,y:t}}});M.scrollMargins=c1;M.darkTheme=Tu;M.cspNonce=R.define({combine:i=>i.length?i[0]:""});M.contentAttributes=Fu;M.editorAttributes=o1;M.lineWrapping=M.contentAttributes.of({class:"cm-lineWrapping"});M.announce=Y.define();var F$=4096,x0={},za=class i{constructor(e,t,n,r,s,o){this.from=e,this.to=t,this.dir=n,this.isolates=r,this.fresh=s,this.order=o}static update(e,t){if(t.empty&&!e.some(s=>s.fresh))return e;let n=[],r=e.length?e[e.length-1].dir:re.LTR;for(let s=Math.max(0,e.length-10);s=0;r--){let s=n[r],o=typeof s=="function"?s(i):s;o&&zu(o,t)}return t}var H$=_.mac?"mac":_.windows?"win":_.linux?"linux":"key";function K$(i,e){let t=i.split(/-(?!$)/),n=t[t.length-1];n=="Space"&&(n=" ");let r,s,o,l;for(let a=0;an.concat(r),[]))),t}function C1(i,e,t){return T1(P1(i.state),e,i,t)}var Ii=null,eP=4e3;function tP(i,e=H$){let t=Object.create(null),n=Object.create(null),r=(o,l)=>{let a=n[o];if(a==null)n[o]=l;else if(a!=l)throw new Error("Key binding "+o+" is used both as a regular binding and as a multi-stroke prefix")},s=(o,l,a,c,h)=>{var f,u;let d=t[o]||(t[o]=Object.create(null)),p=l.split(/ (?!$)/).map(b=>K$(b,e));for(let b=1;b{let X=Ii={view:x,prefix:w,scope:o};return setTimeout(()=>{Ii==X&&(Ii=null)},eP),!0}]})}let O=p.join(" ");r(O,!1);let g=d[O]||(d[O]={preventDefault:!1,stopPropagation:!1,run:((u=(f=d._any)===null||f===void 0?void 0:f.run)===null||u===void 0?void 0:u.slice())||[]});a&&g.run.push(a),c&&(g.preventDefault=!0),h&&(g.stopPropagation=!0)};for(let o of i){let l=o.scope?o.scope.split(" "):["editor"];if(o.any)for(let c of l){let h=t[c]||(t[c]=Object.create(null));h._any||(h._any={preventDefault:!1,stopPropagation:!1,run:[]});let{any:f}=o;for(let u in h)h[u].run.push(d=>f(d,Eu))}let a=o[e]||o.key;if(a)for(let c of l)s(c,a,o.run,o.preventDefault,o.stopPropagation),o.shift&&s(c,"Shift-"+a,o.shift,o.preventDefault,o.stopPropagation)}return t}var Eu=null;function T1(i,e,t,n){Eu=e;let r=Gm(e),s=Ze(r,0),o=mt(s)==r.length&&r!=" ",l="",a=!1,c=!1,h=!1;Ii&&Ii.view==t&&Ii.scope==n&&(l=Ii.prefix+" ",m1.indexOf(e.keyCode)<0&&(c=!0,Ii=null));let f=new Set,u=g=>{if(g){for(let b of g.run)if(!f.has(b)&&(f.add(b),b(t)))return g.stopPropagation&&(h=!0),!0;g.preventDefault&&(g.stopPropagation&&(h=!0),c=!0)}return!1},d=i[n],p,O;return d&&(u(d[l+Qa(r,e,!o)])?a=!0:o&&(e.altKey||e.metaKey||e.ctrlKey)&&!(_.windows&&e.ctrlKey&&e.altKey)&&!(_.mac&&e.altKey&&!(e.ctrlKey||e.metaKey))&&(p=Si[e.keyCode])&&p!=r?(u(d[l+Qa(p,e,!0)])||e.shiftKey&&(O=Br[e.keyCode])!=r&&O!=p&&u(d[l+Qa(O,e,!1)]))&&(a=!0):o&&e.shiftKey&&u(d[l+Qa(r,e,!0)])&&(a=!0),!a&&u(d._any)&&(a=!0)),c&&(a=!0),a&&h&&e.stopPropagation(),Eu=null,a}var jn=class i{constructor(e,t,n,r,s){this.className=e,this.left=t,this.top=n,this.width=r,this.height=s}draw(){let e=document.createElement("div");return e.className=this.className,this.adjust(e),e}update(e,t){return t.className!=this.className?!1:(this.adjust(e),!0)}adjust(e){e.style.left=this.left+"px",e.style.top=this.top+"px",this.width!=null&&(e.style.width=this.width+"px"),e.style.height=this.height+"px"}eq(e){return this.left==e.left&&this.top==e.top&&this.width==e.width&&this.height==e.height&&this.className==e.className}static forRange(e,t,n){if(n.empty){let r=e.coordsAtPos(n.head,n.assoc||1);if(!r)return[];let s=A1(e);return[new i(t,r.left-s.left,r.top-s.top,null,r.bottom-r.top)]}else return iP(e,t,n)}};function A1(i){let e=i.scrollDOM.getBoundingClientRect();return{left:(i.textDirection==re.LTR?e.left:e.right-i.scrollDOM.clientWidth*i.scaleX)-i.scrollDOM.scrollLeft*i.scaleX,top:e.top-i.scrollDOM.scrollTop*i.scaleY}}function Q0(i,e,t,n){let r=i.coordsAtPos(e,t*2);if(!r)return n;let s=i.dom.getBoundingClientRect(),o=(r.top+r.bottom)/2,l=i.posAtCoords({x:s.left+1,y:o}),a=i.posAtCoords({x:s.right-1,y:o});return l==null||a==null?n:{from:Math.max(n.from,Math.min(l,a)),to:Math.min(n.to,Math.max(l,a))}}function iP(i,e,t){if(t.to<=i.viewport.from||t.from>=i.viewport.to)return[];let n=Math.max(t.from,i.viewport.from),r=Math.min(t.to,i.viewport.to),s=i.textDirection==re.LTR,o=i.contentDOM,l=o.getBoundingClientRect(),a=A1(i),c=o.querySelector(".cm-line"),h=c&&window.getComputedStyle(c),f=l.left+(h?parseInt(h.paddingLeft)+Math.min(0,parseInt(h.textIndent)):0),u=l.right-(h?parseInt(h.paddingRight):0),d=pu(i,n,1),p=pu(i,r,-1),O=d.type==Le.Text?d:null,g=p.type==Le.Text?p:null;if(O&&(i.lineWrapping||d.widgetLineBreaks)&&(O=Q0(i,n,1,O)),g&&(i.lineWrapping||p.widgetLineBreaks)&&(g=Q0(i,r,-1,g)),O&&g&&O.from==g.from&&O.to==g.to)return w(x(t.from,t.to,O));{let C=O?x(t.from,null,O):X(d,!1),T=g?x(null,t.to,g):X(p,!0),y=[];return(O||d).to<(g||p).from-(O&&g?1:0)||d.widgetLineBreaks>1&&C.bottom+i.defaultLineHeight/2P&&D.from=N)break;je>V&&k(Math.max(ie,V),C==null&&ie<=P,Math.min(je,N),T==null&&je>=E,H.dir)}if(V=j.to+1,V>=N)break}return $.length==0&&k(P,C==null,E,T==null,i.textDirection),{top:Q,bottom:S,horizontal:$}}function X(C,T){let y=l.top+(T?C.top:C.bottom);return{top:y,bottom:y,horizontal:[]}}}function nP(i,e){return i.constructor==e.constructor&&i.eq(e)}var Zu=class{constructor(e,t){this.view=e,this.layer=t,this.drawn=[],this.scaleX=1,this.scaleY=1,this.measureReq={read:this.measure.bind(this),write:this.draw.bind(this)},this.dom=e.scrollDOM.appendChild(document.createElement("div")),this.dom.classList.add("cm-layer"),t.above&&this.dom.classList.add("cm-layer-above"),t.class&&this.dom.classList.add(t.class),this.scale(),this.dom.setAttribute("aria-hidden","true"),this.setOrder(e.state),e.requestMeasure(this.measureReq),t.mount&&t.mount(this.dom,e)}update(e){e.startState.facet(Ta)!=e.state.facet(Ta)&&this.setOrder(e.state),(this.layer.update(e,this.dom)||e.geometryChanged)&&(this.scale(),e.view.requestMeasure(this.measureReq))}docViewUpdate(e){this.layer.updateOnDocViewUpdate!==!1&&e.requestMeasure(this.measureReq)}setOrder(e){let t=0,n=e.facet(Ta);for(;t!nP(t,this.drawn[n]))){let t=this.dom.firstChild,n=0;for(let r of e)r.update&&t&&r.constructor&&this.drawn[n].constructor&&r.update(t,this.drawn[n])?(t=t.nextSibling,n++):this.dom.insertBefore(r.draw(),t);for(;t;){let r=t.nextSibling;t.remove(),t=r}this.drawn=e,_.webkit&&(this.dom.style.display=this.dom.firstChild?"":"none")}}destroy(){this.layer.destroy&&this.layer.destroy(this.dom,this.view),this.dom.remove()}},Ta=R.define();function M1(i){return[se.define(e=>new Zu(e,i)),Ta.of(i)]}var ts=R.define({combine(i){return Ye(i,{cursorBlinkRate:1200,drawRangeCursor:!0,iosSelectionHandles:!0},{cursorBlinkRate:(e,t)=>Math.min(e,t),drawRangeCursor:(e,t)=>e||t})}});function R1(i={}){return[ts.of(i),rP,sP,lP,n1.of(!0)]}function X1(i){return i.startState.facet(ts)!=i.state.facet(ts)}var rP=M1({above:!0,markers(i){let{state:e}=i,t=e.facet(ts),n=[];for(let r of e.selection.ranges){let s=r==e.selection.main;if(r.empty||t.drawRangeCursor&&!(s&&_.ios&&t.iosSelectionHandles)){let o=s?"cm-cursor cm-cursor-primary":"cm-cursor cm-cursor-secondary",l=r.empty?r:v.cursor(r.head,r.assoc);for(let a of jn.forRange(i,o,l))n.push(a)}}return n},update(i,e){i.transactions.some(n=>n.selection)&&(e.style.animationName=e.style.animationName=="cm-blink"?"cm-blink2":"cm-blink");let t=X1(i);return t&&v0(i.state,e),i.docChanged||i.selectionSet||t},mount(i,e){v0(e.state,i)},class:"cm-cursorLayer"});function v0(i,e){e.style.animationDuration=i.facet(ts).cursorBlinkRate+"ms"}var sP=M1({above:!1,markers(i){let e=[],{main:t,ranges:n}=i.state.selection;for(let r of n)if(!r.empty)for(let s of jn.forRange(i,"cm-selectionBackground",r))e.push(s);if(_.ios&&!t.empty&&i.state.facet(ts).iosSelectionHandles){for(let r of jn.forRange(i,"cm-selectionHandle cm-selectionHandle-start",v.cursor(t.from,1)))e.push(r);for(let r of jn.forRange(i,"cm-selectionHandle cm-selectionHandle-end",v.cursor(t.to,1)))e.push(r)}return e},update(i,e){return i.docChanged||i.selectionSet||i.viewportChanged||X1(i)},class:"cm-selectionLayer"}),oP=_.gecko&&_.gecko_version==153?"#ffffff01":"transparent",lP=at.highest(M.theme({".cm-line":{"& ::selection, &::selection":{backgroundColor:`${oP} !important`},caretColor:"transparent !important"},".cm-content":{caretColor:"transparent !important","& :focus":{caretColor:"initial !important","&::selection, & ::selection":{backgroundColor:"Highlight !important"}}}})),E1=Y.define({map(i,e){return i==null?null:e.mapPos(i)}}),so=de.define({create(){return null},update(i,e){return i!=null&&(i=e.changes.mapPos(i)),e.effects.reduce((t,n)=>n.is(E1)?n.value:t,i)}}),aP=se.fromClass(class{constructor(i){this.view=i,this.cursor=null,this.measureReq={read:this.readPos.bind(this),write:this.drawCursor.bind(this)}}update(i){var e;let t=i.state.field(so);t==null?this.cursor!=null&&((e=this.cursor)===null||e===void 0||e.remove(),this.cursor=null):(this.cursor||(this.cursor=this.view.scrollDOM.appendChild(document.createElement("div")),this.cursor.className="cm-dropCursor"),(i.startState.field(so)!=t||i.docChanged||i.geometryChanged)&&this.view.requestMeasure(this.measureReq))}readPos(){let{view:i}=this,e=i.state.field(so),t=e!=null&&i.coordsAtPos(e);if(!t)return null;let n=i.scrollDOM.getBoundingClientRect();return{left:t.left-n.left+i.scrollDOM.scrollLeft*i.scaleX,top:t.top-n.top+i.scrollDOM.scrollTop*i.scaleY,height:t.bottom-t.top}}drawCursor(i){if(this.cursor){let{scaleX:e,scaleY:t}=this.view;i?(this.cursor.style.left=i.left/e+"px",this.cursor.style.top=i.top/t+"px",this.cursor.style.height=i.height/t+"px"):this.cursor.style.left="-100000px"}}destroy(){this.cursor&&this.cursor.remove()}setDropPos(i){this.view.state.field(so)!=i&&this.view.dispatch({effects:E1.of(i)})}},{eventObservers:{dragover(i){this.setDropPos(this.view.posAtCoords({x:i.clientX,y:i.clientY}))},dragleave(i){(i.target==this.view.contentDOM||!this.view.contentDOM.contains(i.relatedTarget))&&this.setDropPos(null)},dragend(){this.setDropPos(null)},drop(){this.setDropPos(null)}}});function Z1(){return[so,aP]}function $0(i,e,t,n,r){e.lastIndex=0;for(let s=i.iterRange(t,n),o=t,l;!s.next().done;o+=s.value.length)if(!s.lineBreak)for(;l=e.exec(s.value);)r(o+l.index,l)}function cP(i,e){let t=i.visibleRanges;if(t.length==1&&t[0].from==i.viewport.from&&t[0].to==i.viewport.to)return t;let n=[];for(let{from:r,to:s}of t)r=Math.max(i.state.doc.lineAt(r).from,r-e),s=Math.min(i.state.doc.lineAt(s).to,s+e),n.length&&n[n.length-1].to>=r?n[n.length-1].to=s:n.push({from:r,to:s});return n}var _u=class{constructor(e){let{regexp:t,decoration:n,decorate:r,boundary:s,maxLength:o=1e3}=e;if(!t.global)throw new RangeError("The regular expression given to MatchDecorator should have its 'g' flag set");if(this.regexp=t,r)this.addMatch=(l,a,c,h)=>r(h,c,c+l[0].length,l,a);else if(typeof n=="function")this.addMatch=(l,a,c,h)=>{let f=n(l,a,c);f&&h(c,c+l[0].length,f)};else if(n)this.addMatch=(l,a,c,h)=>h(c,c+l[0].length,n);else throw new RangeError("Either 'decorate' or 'decoration' should be provided to MatchDecorator");this.boundary=s,this.maxLength=o}createDeco(e){let t=new lt,n=t.add.bind(t);for(let{from:r,to:s}of cP(e,this.maxLength))$0(e.state.doc,this.regexp,r,s,(o,l)=>this.addMatch(l,e,o,n));return t.finish()}updateDeco(e,t){let n=1e9,r=-1;return e.docChanged&&e.changes.iterChanges((s,o,l,a)=>{a>=e.view.viewport.from&&l<=e.view.viewport.to&&(n=Math.min(l,n),r=Math.max(a,r))}),e.viewportMoved||r-n>1e3?this.createDeco(e.view):r>-1?this.updateRange(e.view,t.map(e.changes),n,r):t}updateRange(e,t,n,r){for(let s of e.visibleRanges){let o=Math.max(s.from,n),l=Math.min(s.to,r);if(l>=o){let a=e.state.doc.lineAt(o),c=a.toa.from;o--)if(this.boundary.test(a.text[o-1-a.from])){h=o;break}for(;lu.push(b.range(O,g));if(a==c)for(this.regexp.lastIndex=h-a.from;(d=this.regexp.exec(a.text))&&d.indexthis.addMatch(g,e,O,p));t=t.update({filterFrom:h,filterTo:f,filter:(O,g)=>Of,add:u})}}return t}},Lu=/x/.unicode!=null?"gu":"g",hP=new RegExp(`[\0-\b +-\x7F-\x9F\xAD\u061C\u200B\u200E\u200F\u2028\u2029\u202D\u202E\u2066\u2067\u2069\uFEFF\uFFF9-\uFFFC]`,Lu),fP={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8294:"left-to-right isolate",8295:"right-to-left isolate",8297:"pop directional isolate",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"},Nf=null;function uP(){var i;if(Nf==null&&typeof document<"u"&&document.body){let e=document.body.style;Nf=((i=e.tabSize)!==null&&i!==void 0?i:e.MozTabSize)!=null}return Nf||!1}var Aa=R.define({combine(i){let e=Ye(i,{render:null,specialChars:hP,addSpecialChars:null});return(e.replaceTabs=!uP())&&(e.specialChars=new RegExp(" |"+e.specialChars.source,Lu)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+"|"+e.addSpecialChars.source,Lu)),e}});function _1(i={}){return[Aa.of(i),dP()]}var P0=null;function dP(){return P0||(P0=se.fromClass(class{constructor(i){this.view=i,this.decorations=L.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(i.state.facet(Aa)),this.decorations=this.decorator.createDeco(i)}makeDecorator(i){return new _u({regexp:i.specialChars,decoration:(e,t,n)=>{let{doc:r}=t.state,s=Ze(e[0],0);if(s==9){let o=r.lineAt(n),l=t.state.tabSize,a=Ke(o.text,l,n-o.from);return L.replace({widget:new ju((l-a%l)*this.view.defaultCharacterWidth/this.view.scaleX)})}return this.decorationCache[s]||(this.decorationCache[s]=L.replace({widget:new Du(i,s)}))},boundary:i.replaceTabs?void 0:/[^]/})}update(i){let e=i.state.facet(Aa);i.startState.facet(Aa)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(i.view)):this.decorations=this.decorator.updateDeco(i,this.decorations)}},{decorations:i=>i.decorations}))}var pP="\u2022";function OP(i){return i>=32?pP:i==10?"\u2424":String.fromCharCode(9216+i)}var Du=class extends De{constructor(e,t){super(),this.options=e,this.code=t}eq(e){return e.code==this.code}toDOM(e){let t=OP(this.code),n=e.state.phrase("Control character")+" "+(fP[this.code]||"0x"+this.code.toString(16)),r=this.options.render&&this.options.render(this.code,n,t);if(r)return r;let s=document.createElement("span");return s.textContent=t,s.title=n,s.setAttribute("aria-label",n),s.className="cm-specialChar",s}ignoreEvent(){return!1}},ju=class extends De{constructor(e){super(),this.width=e}eq(e){return e.width==this.width}toDOM(){let e=document.createElement("span");return e.textContent=" ",e.className="cm-tab",e.style.width=this.width+"px",e}ignoreEvent(){return!1}};function L1(){return mP}var gP=L.line({class:"cm-activeLine"}),mP=se.fromClass(class{constructor(i){this.decorations=this.getDeco(i)}update(i){(i.docChanged||i.selectionSet)&&(this.decorations=this.getDeco(i.view))}getDeco(i){let e=-1,t=[];for(let n of i.state.selection.ranges){let r=i.lineBlockAt(n.head);r.from>e&&(t.push(gP.range(r.from)),e=r.from)}return L.set(t)}},{decorations:i=>i.decorations});var Iu=2e3;function bP(i,e,t){let n=Math.min(e.line,t.line),r=Math.max(e.line,t.line),s=[];if(e.off>Iu||t.off>Iu||e.col<0||t.col<0){let o=Math.min(e.off,t.off),l=Math.max(e.off,t.off);for(let a=n;a<=r;a++){let c=i.doc.line(a);c.length<=l&&s.push(v.range(c.from+o,c.to+l))}}else{let o=Math.min(e.col,t.col),l=Math.max(e.col,t.col);for(let a=n;a<=r;a++){let c=i.doc.line(a),h=ga(c.text,o,i.tabSize,!0);if(h<0)s.push(v.cursor(c.to));else{let f=ga(c.text,l,i.tabSize);s.push(v.range(c.from+h,c.from+f))}}}return s}function yP(i,e){let t=i.coordsAtPos(i.viewport.from);return t?Math.round(Math.abs((t.left-e)/i.defaultCharacterWidth)):-1}function C0(i,e){let t=i.posAtCoords({x:e.clientX,y:e.clientY},!1),n=i.state.doc.lineAt(t),r=t-n.from,s=r>Iu?-1:r==n.length?yP(i,e.clientX):Ke(n.text,i.state.tabSize,t-n.from);return{line:n.number,col:s,off:r}}function SP(i,e){let t=C0(i,e),n=i.state.selection;return t?{update(r){if(r.docChanged){let s=r.changes.mapPos(r.startState.doc.line(t.line).from),o=r.state.doc.lineAt(s);t={line:o.number,col:t.col,off:Math.min(t.off,o.length)},n=n.map(r.changes)}},get(r,s,o){let l=C0(i,r);if(!l)return n;let a=bP(i.state,t,l);return a.length?o?v.create(a.concat(n.ranges)):v.create(a):n}}:null}function D1(i){let e=i?.eventFilter||(t=>t.altKey&&t.button==0);return M.mouseSelectionStyle.of((t,n)=>e(n)?SP(t,n):null)}var xP={Alt:[18,i=>!!i.altKey],Control:[17,i=>!!i.ctrlKey],Shift:[16,i=>!!i.shiftKey],Meta:[91,i=>!!i.metaKey]},wP={style:"cursor: crosshair"};function j1(i={}){let[e,t]=xP[i.key||"Alt"],n=se.fromClass(class{constructor(r){this.view=r,this.isDown=!1}set(r){this.isDown!=r&&(this.isDown=r,this.view.update([]))}},{eventObservers:{keydown(r){this.set(r.keyCode==e||t(r))},keyup(r){(r.keyCode==e||!t(r))&&this.set(!1)},mousemove(r){this.set(t(r))}}});return[n,M.contentAttributes.of(r=>{var s;return!((s=r.plugin(n))===null||s===void 0)&&s.isDown?wP:null})]}var va="-10000px",Wa=class{constructor(e,t,n,r){this.facet=t,this.createTooltipView=n,this.removeTooltipView=r,this.input=e.state.facet(t),this.tooltips=this.input.filter(o=>o);let s=null;this.tooltipViews=this.tooltips.map(o=>s=n(o,s))}update(e,t){var n;let r=e.state.facet(this.facet),s=r.filter(a=>a);if(r===this.input){for(let a of this.tooltipViews)a.update&&a.update(e);return!1}let o=[],l=t?[]:null;for(let a=0;at[c]=a),t.length=l.length),this.input=r,this.tooltips=s,this.tooltipViews=o,!0}};function kP(i){let e=i.dom.ownerDocument.documentElement;return{top:0,left:0,bottom:e.clientHeight,right:e.clientWidth}}var qf=R.define({combine:i=>{var e,t,n;return{position:_.ios?"absolute":((e=i.find(r=>r.position))===null||e===void 0?void 0:e.position)||"fixed",parent:((t=i.find(r=>r.parent))===null||t===void 0?void 0:t.parent)||null,tooltipSpace:((n=i.find(r=>r.tooltipSpace))===null||n===void 0?void 0:n.tooltipSpace)||kP}}}),T0=new WeakMap,ed=se.fromClass(class{constructor(i){this.view=i,this.above=[],this.inView=!0,this.madeAbsolute=!1,this.lastTransaction=0,this.measureTimeout=-1;let e=i.state.facet(qf);this.position=e.position,this.parent=e.parent,this.classes=i.themeClasses,this.createContainer(),this.measureReq={read:this.readMeasure.bind(this),write:this.writeMeasure.bind(this),key:this},this.resizeObserver=typeof ResizeObserver=="function"?new ResizeObserver(()=>this.measureSoon()):null,this.manager=new Wa(i,xo,(t,n)=>this.createTooltip(t,n),t=>{this.resizeObserver&&this.resizeObserver.unobserve(t.dom),t.dom.remove()}),this.above=this.manager.tooltips.map(t=>!!t.above),this.intersectionObserver=typeof IntersectionObserver=="function"?new IntersectionObserver(t=>{Date.now()>this.lastTransaction-50&&t.length>0&&t[t.length-1].intersectionRatio<1&&this.measureSoon()},{threshold:[1]}):null,this.observeIntersection(),i.win.addEventListener("resize",this.measureSoon=this.measureSoon.bind(this)),this.maybeMeasure()}createContainer(){this.parent?(this.container=document.createElement("div"),this.container.style.position="relative",this.container.className=this.view.themeClasses,this.parent.appendChild(this.container)):this.container=this.view.dom}observeIntersection(){if(this.intersectionObserver){this.intersectionObserver.disconnect();for(let i of this.manager.tooltipViews)this.intersectionObserver.observe(i.dom)}}measureSoon(){this.measureTimeout<0&&(this.measureTimeout=setTimeout(()=>{this.measureTimeout=-1,this.maybeMeasure()},50))}update(i){i.transactions.length&&(this.lastTransaction=Date.now());let e=this.manager.update(i,this.above);e&&this.observeIntersection();let t=e||i.geometryChanged,n=i.state.facet(qf);if(n.position!=this.position&&!this.madeAbsolute){this.position=n.position;for(let r of this.manager.tooltipViews)r.dom.style.position=this.position;t=!0}if(n.parent!=this.parent){this.parent&&this.container.remove(),this.parent=n.parent,this.createContainer();for(let r of this.manager.tooltipViews)this.container.appendChild(r.dom);t=!0}else this.parent&&this.view.themeClasses!=this.classes&&(this.classes=this.container.className=this.view.themeClasses);t&&this.maybeMeasure()}createTooltip(i,e){let t=i.create(this.view),n=e?e.dom:null;if(t.dom.classList.add("cm-tooltip"),i.arrow&&!t.dom.querySelector(".cm-tooltip > .cm-tooltip-arrow")){let r=document.createElement("div");r.className="cm-tooltip-arrow",t.dom.appendChild(r)}return t.dom.style.position=this.position,t.dom.style.top=va,t.dom.style.left="0px",this.container.insertBefore(t.dom,n),t.mount&&t.mount(this.view),this.resizeObserver&&this.resizeObserver.observe(t.dom),t}destroy(){var i,e,t;this.view.win.removeEventListener("resize",this.measureSoon);for(let n of this.manager.tooltipViews)n.dom.remove(),(i=n.destroy)===null||i===void 0||i.call(n);this.parent&&this.container.remove(),(e=this.resizeObserver)===null||e===void 0||e.disconnect(),(t=this.intersectionObserver)===null||t===void 0||t.disconnect(),clearTimeout(this.measureTimeout)}readMeasure(){let i=1,e=1,t=!1;if(this.position=="fixed"&&this.manager.tooltipViews.length){let{dom:s}=this.manager.tooltipViews[0];if(_.safari){let o=s.getBoundingClientRect();t=Math.abs(o.top+1e4)>1||Math.abs(o.left)>1}else t=!!s.offsetParent&&s.offsetParent!=this.container.ownerDocument.body}if(t||this.position=="absolute")if(this.parent){let s=this.parent.getBoundingClientRect();s.width&&s.height&&(i=s.width/this.parent.offsetWidth,e=s.height/this.parent.offsetHeight)}else({scaleX:i,scaleY:e}=this.view.viewState);let n=this.view.scrollDOM.getBoundingClientRect(),r=Ku(this.view);return{visible:{left:n.left+r.left,top:n.top+r.top,right:n.right-r.right,bottom:n.bottom-r.bottom},parent:this.parent?this.container.getBoundingClientRect():this.view.dom.getBoundingClientRect(),pos:this.manager.tooltips.map((s,o)=>{let l=this.manager.tooltipViews[o];return l.getCoords?l.getCoords(s.pos):this.view.coordsAtPos(s.pos)}),size:this.manager.tooltipViews.map(({dom:s})=>s.getBoundingClientRect()),space:this.view.state.facet(qf).tooltipSpace(this.view),scaleX:i,scaleY:e,makeAbsolute:t}}writeMeasure(i){var e;if(i.makeAbsolute){this.madeAbsolute=!0,this.position="absolute";for(let l of this.manager.tooltipViews)l.dom.style.position="absolute"}let{visible:t,space:n,scaleX:r,scaleY:s}=i,o=[];for(let l=0;l=Math.min(t.bottom,n.bottom)||f.rightMath.min(t.right,n.right)+.1)){h.style.top=va;continue}let d=a.arrow?c.dom.querySelector(".cm-tooltip-arrow"):null,p=d?7:0,O=u.right-u.left,g=(e=T0.get(c))!==null&&e!==void 0?e:u.bottom-u.top,b=c.offset||vP,w=this.view.textDirection==re.LTR,x=u.width>n.right-n.left?w?n.left:n.right-u.width:w?Math.max(n.left,Math.min(f.left-(d?14:0)+b.x,n.right-O)):Math.min(Math.max(n.left,f.left-O+(d?14:0)-b.x),n.right-O),X=this.above[l];!a.strictSide&&(X?f.top-g-p-b.yn.bottom)&&X==n.bottom-f.bottom>f.top-n.top&&(X=this.above[l]=!X);let C=(X?f.top-n.top:n.bottom-f.bottom)-p;if(Cx&&Q.topT&&(T=X?Q.top-g-2-p:Q.bottom+p+2);if(this.position=="absolute"?(h.style.top=(T-i.parent.top)/s+"px",A0(h,(x-i.parent.left)/r)):(h.style.top=T/s+"px",A0(h,x/r)),d){let Q=f.left+(w?b.x:-b.x)-(x+14-7);d.style.left=Q/r+"px"}c.overlap!==!0&&o.push({left:x,top:T,right:y,bottom:T+g}),h.classList.toggle("cm-tooltip-above",X),h.classList.toggle("cm-tooltip-below",!X),c.positioned&&c.positioned(i.space)}}maybeMeasure(){if(this.manager.tooltips.length&&(this.view.inView&&this.view.requestMeasure(this.measureReq),this.inView!=this.view.inView&&(this.inView=this.view.inView,!this.inView)))for(let i of this.manager.tooltipViews)i.dom.style.top=va}},{eventObservers:{scroll(){this.maybeMeasure()}}});function A0(i,e){let t=parseInt(i.style.left,10);(isNaN(t)||Math.abs(e-t)>1)&&(i.style.left=e+"px")}var QP=M.baseTheme({".cm-tooltip":{zIndex:500,boxSizing:"border-box"},"&light .cm-tooltip":{border:"1px solid #bbb",backgroundColor:"#f5f5f5"},"&light .cm-tooltip-section:not(:first-child)":{borderTop:"1px solid #bbb"},"&dark .cm-tooltip":{backgroundColor:"#333338",color:"white"},".cm-tooltip-arrow":{height:"7px",width:"14px",position:"absolute",zIndex:-1,overflow:"hidden","&:before, &:after":{content:"''",position:"absolute",width:0,height:0,borderLeft:"7px solid transparent",borderRight:"7px solid transparent"},".cm-tooltip-above &":{bottom:"-7px","&:before":{borderTop:"7px solid #bbb"},"&:after":{borderTop:"7px solid #f5f5f5",bottom:"1px"}},".cm-tooltip-below &":{top:"-7px","&:before":{borderBottom:"7px solid #bbb"},"&:after":{borderBottom:"7px solid #f5f5f5",top:"1px"}}},"&dark .cm-tooltip .cm-tooltip-arrow":{"&:before":{borderTopColor:"#333338",borderBottomColor:"#333338"},"&:after":{borderTopColor:"transparent",borderBottomColor:"transparent"}}}),vP={x:0,y:0},xo=R.define({enables:[ed,QP]}),Na=R.define({combine:i=>i.reduce((e,t)=>e.concat(t),[])}),qa=class i{static create(e){return new i(e)}constructor(e){this.view=e,this.mounted=!1,this.dom=document.createElement("div"),this.dom.classList.add("cm-tooltip-hover"),this.manager=new Wa(e,Na,(t,n)=>this.createHostedView(t,n),t=>t.dom.remove())}createHostedView(e,t){let n=e.create(this.view);return n.dom.classList.add("cm-tooltip-section"),this.dom.insertBefore(n.dom,t?t.dom.nextSibling:this.dom.firstChild),this.mounted&&n.mount&&n.mount(this.view),n}mount(e){for(let t of this.manager.tooltipViews)t.mount&&t.mount(e);this.mounted=!0}positioned(e){for(let t of this.manager.tooltipViews)t.positioned&&t.positioned(e)}update(e){this.manager.update(e)}destroy(){var e;for(let t of this.manager.tooltipViews)(e=t.destroy)===null||e===void 0||e.call(t)}passProp(e){let t;for(let n of this.manager.tooltipViews){let r=n[e];if(r!==void 0){if(t===void 0)t=r;else if(t!==r)return}}return t}get offset(){return this.passProp("offset")}get getCoords(){return this.passProp("getCoords")}get overlap(){return this.passProp("overlap")}get resize(){return this.passProp("resize")}},$P=xo.compute([Na],i=>{let e=i.facet(Na);return e.length===0?null:{pos:Math.min(...e.map(t=>t.pos)),end:Math.max(...e.map(t=>{var n;return(n=t.end)!==null&&n!==void 0?n:t.pos})),create:qa.create,above:e[0].above,arrow:e.some(t=>t.arrow)}}),I1=R.define(),Uu=class{constructor(e,t,n,r,s,o){this.view=e,this.source=t,this.field=n,this.locked=r,this.setHover=s,this.hoverTime=o,this.hoverTimeout=-1,this.restartTimeout=-1,this.pending=null,this.lastMove={x:0,y:0,target:e.dom,time:0},this.checkHover=this.checkHover.bind(this),e.dom.addEventListener("mouseleave",this.mouseleave=this.mouseleave.bind(this)),e.dom.addEventListener("mousemove",this.mousemove=this.mousemove.bind(this))}update(e){this.pending&&(this.pending=null,clearTimeout(this.restartTimeout),this.restartTimeout=setTimeout(()=>this.startHover(),20))}get active(){return this.view.state.field(this.field)}checkHover(){if(this.hoverTimeout=-1,this.active.length)return;let e=Date.now()-this.lastMove.time;eo.bottom||t.xo.right+e.defaultCharacterWidth)return;let l=e.bidiSpans(e.state.doc.lineAt(r)).find(c=>c.from<=r&&c.to>=r),a=l&&l.dir==re.RTL?-1:1;s=t.x{if(l&&!(Array.isArray(l)&&!l.length)){let a=Array.isArray(l)?l:[l];r&&this.locked.set(a,r),e.dispatch({effects:this.setHover.of(a)})}};if(s&&"then"in s){let l=this.pending={pos:t};s.then(a=>{this.pending==l&&(this.pending=null,o(a))},a=>_e(e.state,a,"hover tooltip"))}else o(s)}get tooltip(){let e=this.view.plugin(ed),t=e?e.manager.tooltips.findIndex(n=>n.create==qa.create):-1;return t>-1?e.manager.tooltipViews[t]:null}mousemove(e){var t,n;this.lastMove={x:e.clientX,y:e.clientY,target:e.target,time:Date.now()},this.hoverTimeout<0&&(this.hoverTimeout=setTimeout(this.checkHover,this.hoverTime));let{active:r,tooltip:s}=this;if(r.length&&!this.locked.has(r)&&s&&!PP(s.dom,e)||this.pending){let{pos:o}=r[0]||this.pending,l=(n=(t=r[0])===null||t===void 0?void 0:t.end)!==null&&n!==void 0?n:o;(o==l?this.view.posAtCoords(this.lastMove)!=o:!CP(this.view,o,l,e.clientX,e.clientY))&&(this.view.dispatch({effects:this.setHover.of([])}),this.pending=null)}}mouseleave(e){clearTimeout(this.hoverTimeout),this.hoverTimeout=-1;let{active:t}=this;if(t.length&&!this.locked.has(t)){let{tooltip:n}=this;n&&n.dom.contains(e.relatedTarget)?this.watchTooltipLeave(n.dom):this.view.dispatch({effects:this.setHover.of([])})}}watchTooltipLeave(e){let t=n=>{e.removeEventListener("mouseleave",t);let{active:r}=this;r.length&&!this.locked.has(r)&&!this.view.dom.contains(n.relatedTarget)&&this.view.dispatch({effects:this.setHover.of([])})};e.addEventListener("mouseleave",t)}destroy(){clearTimeout(this.hoverTimeout),clearTimeout(this.restartTimeout),this.view.dom.removeEventListener("mouseleave",this.mouseleave),this.view.dom.removeEventListener("mousemove",this.mousemove)}},$a=4;function PP(i,e){let{left:t,right:n,top:r,bottom:s}=i.getBoundingClientRect(),o;if(o=i.querySelector(".cm-tooltip-arrow")){let l=o.getBoundingClientRect();r=Math.min(l.top,r),s=Math.max(l.bottom,s)}return e.clientX>=t-$a&&e.clientX<=n+$a&&e.clientY>=r-$a&&e.clientY<=s+$a}function CP(i,e,t,n,r,s){let o=i.scrollDOM.getBoundingClientRect(),l=i.documentTop+i.documentPadding.top+i.contentHeight;if(o.left>n||o.rightr||Math.min(o.bottom,l)=e&&a<=t}function U1(i,e={}){let t=Y.define(),n=new WeakMap,r=de.define({create(){return[]},update(o,l){let a=n.get(o);if(o.length&&(e.hideOnChange&&(l.docChanged||l.selection)?o=[]:a&&a(l)?o=[]:e.hideOn&&(o=o.filter(c=>!e.hideOn(l,c)))),l.docChanged&&o.length){let c=[];for(let h of o){let f=l.changes.mapPos(h.pos,-1,$e.TrackDel);if(f!=null){let u=Object.assign(Object.create(null),h);u.pos=f,u.end!=null&&(u.end=l.changes.mapPos(u.end)),c.push(u)}}o=c}for(let c of l.effects)c.is(t)&&(o=c.value,a=void 0),(c.is(TP)&&!c.value||c.value==r)&&(o=[]);return o.length&&a&&n.set(o,a),o},provide:o=>Na.from(o)}),s=se.define(o=>new Uu(o,i,r,n,t,e.hoverTime||300));return{active:r,extension:[r,s,I1.of(s),$P]}}function B1(i,e,t,n={}){var r;let s=i.state.facet(I1).map(o=>i.plugin(o)).filter(o=>!!o);if(n.tooltip&&n.tooltip.active){let o=s.find(l=>l.field==n.tooltip.active);o&&(s=[o])}for(let o of s)o.activateHover(i,e,t,(r=n.until)!==null&&r!==void 0?r:(()=>!1))}function td(i,e){let t=i.plugin(ed);if(!t)return null;let n=t.manager.tooltips.indexOf(e);return n<0?null:t.manager.tooltipViews[n]}var TP=Y.define();var M0=R.define({combine(i){let e,t;for(let n of i)e=e||n.topContainer,t=t||n.bottomContainer;return{topContainer:e,bottomContainer:t}}});function is(i,e){let t=i.plugin(V1),n=t?t.specs.indexOf(e):-1;return n>-1?t.panels[n]:null}var V1=se.fromClass(class{constructor(i){this.input=i.state.facet(Vn),this.specs=this.input.filter(t=>t),this.panels=this.specs.map(t=>t(i));let e=i.state.facet(M0);this.top=new Wr(i,!0,e.topContainer),this.bottom=new Wr(i,!1,e.bottomContainer),this.top.sync(this.panels.filter(t=>t.top)),this.bottom.sync(this.panels.filter(t=>!t.top));for(let t of this.panels)t.dom.classList.add("cm-panel"),t.mount&&t.mount()}update(i){let e=i.state.facet(M0);this.top.container!=e.topContainer&&(this.top.sync([]),this.top=new Wr(i.view,!0,e.topContainer)),this.bottom.container!=e.bottomContainer&&(this.bottom.sync([]),this.bottom=new Wr(i.view,!1,e.bottomContainer)),this.top.syncClasses(),this.bottom.syncClasses();let t=i.state.facet(Vn);if(t!=this.input){let n=t.filter(a=>a),r=[],s=[],o=[],l=[];for(let a of n){let c=this.specs.indexOf(a),h;c<0?(h=a(i.view),l.push(h)):(h=this.panels[c],h.update&&h.update(i)),r.push(h),(h.top?s:o).push(h)}this.specs=n,this.panels=r,this.top.sync(s),this.bottom.sync(o);for(let a of l)a.dom.classList.add("cm-panel"),a.mount&&a.mount()}else for(let n of this.panels)n.update&&n.update(i)}destroy(){this.top.sync([]),this.bottom.sync([])}},{provide:i=>M.scrollMargins.of(e=>{let t=e.plugin(i);return t&&{top:t.top.scrollMargin(),bottom:t.bottom.scrollMargin()}})}),Wr=class{constructor(e,t,n){this.view=e,this.top=t,this.container=n,this.dom=void 0,this.classes="",this.panels=[],this.syncClasses()}sync(e){for(let t of this.panels)t.destroy&&e.indexOf(t)<0&&t.destroy();this.panels=e,this.syncDOM()}syncDOM(){if(this.panels.length==0){this.dom&&(this.dom.remove(),this.dom=void 0);return}if(!this.dom){this.dom=document.createElement("div"),this.dom.className=this.top?"cm-panels cm-panels-top":"cm-panels cm-panels-bottom";let t=this.container||this.view.dom;t.insertBefore(this.dom,this.top?t.firstChild:null)}let e=this.dom.firstChild;for(let t of this.panels)if(t.dom.parentNode==this.dom){for(;e!=t.dom;)e=R0(e);e=e.nextSibling}else this.dom.insertBefore(t.dom,e);for(;e;)e=R0(e)}scrollMargin(){return!this.dom||this.container?0:Math.max(0,this.top?this.dom.getBoundingClientRect().bottom-Math.max(0,this.view.scrollDOM.getBoundingClientRect().top):Math.min(innerHeight,this.view.scrollDOM.getBoundingClientRect().bottom)-this.dom.getBoundingClientRect().top)}syncClasses(){if(!(!this.container||this.classes==this.view.themeClasses)){for(let e of this.classes.split(" "))e&&this.container.classList.remove(e);for(let e of(this.classes=this.view.themeClasses).split(" "))e&&this.container.classList.add(e)}}};function R0(i){let e=i.nextSibling;return i.remove(),e}var Vn=R.define({enables:V1});function Y1(i,e){let t,n=new Promise(o=>t=o),r=o=>AP(o,e,t);i.state.field(Ma,!1)?i.dispatch({effects:W1.of(r)}):i.dispatch({effects:Y.appendConfig.of(Ma.init(()=>[r]))});let s=N1.of(r);return{close:s,result:n.then(o=>((i.win.queueMicrotask||(a=>i.win.setTimeout(a,10)))(()=>{i.state.field(Ma).indexOf(r)>-1&&i.dispatch({effects:s})}),o))}}function z1(i,e){let t=i.state.field(Ma,!1)||[];for(let n of t){let r=is(i,n);if(r&&r.dom.classList.contains(e))return r}return null}var Ma=de.define({create(){return[]},update(i,e){for(let t of e.effects)t.is(W1)?i=[t.value].concat(i):t.is(N1)&&(i=i.filter(n=>n!=t.value));return i},provide:i=>Vn.computeN([i],e=>e.field(i))}),W1=Y.define(),N1=Y.define();function AP(i,e,t){let n=e.content?e.content(i,()=>o(null)):null;if(!n){if(n=te("form"),e.input){let l=te("input",e.input);/^(text|password|number|email|tel|url)$/.test(l.type)&&l.classList.add("cm-textfield"),l.name||(l.name="input"),n.appendChild(te("label",(e.label||"")+": ",l))}else n.appendChild(document.createTextNode(e.label||""));n.appendChild(document.createTextNode(" ")),n.appendChild(te("button",{class:"cm-button",type:"submit"},e.submitLabel||"OK"))}let r=n.nodeName=="FORM"?[n]:n.querySelectorAll("form");for(let l=0;l{c.keyCode==27?(c.preventDefault(),o(null)):c.keyCode==13&&(c.preventDefault(),o(a))}),a.addEventListener("submit",c=>{c.preventDefault(),o(a)})}let s=te("div",n,te("button",{onclick:()=>o(null),"aria-label":i.state.phrase("close"),class:"cm-dialog-close",type:"button"},["\xD7"]));e.class&&(s.className=e.class),s.classList.add("cm-dialog");function o(l){s.contains(s.ownerDocument.activeElement)&&i.focus(),t(l)}return{dom:s,top:e.top,mount:()=>{if(e.focus){let l;typeof e.focus=="string"?l=n.querySelector(e.focus):l=n.querySelector("input")||n.querySelector("button"),l&&"select"in l?l.select():l&&"focus"in l&&l.focus()}}}}var yt=class extends Ct{compare(e){return this==e||this.constructor==e.constructor&&this.eq(e)}eq(e){return!1}destroy(e){}};yt.prototype.elementClass="";yt.prototype.toDOM=void 0;yt.prototype.mapMode=$e.TrackBefore;yt.prototype.startSide=yt.prototype.endSide=-1;yt.prototype.point=!0;var Ra=R.define(),MP=R.define(),RP={class:"",renderEmptyElements:!1,elementStyle:"",markers:()=>G.empty,lineMarker:()=>null,widgetMarker:()=>null,lineMarkerChange:null,initialSpacer:null,updateSpacer:null,domEventHandlers:{},side:"before"},po=R.define();function id(i){return[q1(),po.of({...RP,...i})]}var Bu=R.define({combine:i=>i.some(e=>e)});function q1(i){let e=[XP];return i&&i.fixed===!1&&e.push(Bu.of(!0)),e}var XP=se.fromClass(class{constructor(i){this.view=i,this.domAfter=null,this.prevViewport=i.viewport,this.dom=document.createElement("div"),this.dom.className="cm-gutters cm-gutters-before",this.dom.setAttribute("aria-hidden","true"),this.dom.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.gutters=i.state.facet(po).map(e=>new Ga(i,e)),this.fixed=!i.state.facet(Bu);for(let e of this.gutters)e.config.side=="after"?this.getDOMAfter().appendChild(e.dom):this.dom.appendChild(e.dom);this.fixed&&(this.dom.style.position="sticky"),this.syncGutters(!1),i.scrollDOM.insertBefore(this.dom,i.contentDOM)}getDOMAfter(){return this.domAfter||(this.domAfter=document.createElement("div"),this.domAfter.className="cm-gutters cm-gutters-after",this.domAfter.setAttribute("aria-hidden","true"),this.domAfter.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.domAfter.style.position=this.fixed?"sticky":"",this.view.scrollDOM.appendChild(this.domAfter)),this.domAfter}update(i){if(this.updateGutters(i)){let e=this.prevViewport,t=i.view.viewport,n=Math.min(e.to,t.to)-Math.max(e.from,t.from);this.syncGutters(n<(t.to-t.from)*.8)}if(i.geometryChanged){let e=this.view.contentHeight/this.view.scaleY+"px";this.dom.style.minHeight=e,this.domAfter&&(this.domAfter.style.minHeight=e)}this.view.state.facet(Bu)!=!this.fixed&&(this.fixed=!this.fixed,this.dom.style.position=this.fixed?"sticky":"",this.domAfter&&(this.domAfter.style.position=this.fixed?"sticky":"")),this.prevViewport=i.view.viewport}syncGutters(i){let e=this.dom.nextSibling;i&&(this.dom.remove(),this.domAfter&&this.domAfter.remove());let t=G.iter(this.view.state.facet(Ra),this.view.viewport.from),n=[],r=this.gutters.map(s=>new Yu(s,this.view.viewport,-this.view.documentPadding.top));for(let s of this.view.viewportLineBlocks)if(n.length&&(n=[]),Array.isArray(s.type)){let o=!0;for(let l of s.type)if(l.type==Le.Text&&o){Vu(t,n,l.from);for(let a of r)a.line(this.view,l,n);o=!1}else if(l.widget)for(let a of r)a.widget(this.view,l)}else if(s.type==Le.Text){Vu(t,n,s.from);for(let o of r)o.line(this.view,s,n)}else if(s.widget)for(let o of r)o.widget(this.view,s);for(let s of r)s.finish();i&&(this.view.scrollDOM.insertBefore(this.dom,e),this.domAfter&&this.view.scrollDOM.appendChild(this.domAfter))}updateGutters(i){let e=i.startState.facet(po),t=i.state.facet(po),n=i.docChanged||i.heightChanged||i.viewportChanged||!G.eq(i.startState.facet(Ra),i.state.facet(Ra),i.view.viewport.from,i.view.viewport.to);if(e==t)for(let r of this.gutters)r.update(i)&&(n=!0);else{n=!0;let r=[];for(let s of t){let o=e.indexOf(s);o<0?r.push(new Ga(this.view,s)):(this.gutters[o].update(i),r.push(this.gutters[o]))}for(let s of this.gutters)s.dom.remove(),r.indexOf(s)<0&&s.destroy();for(let s of r)s.config.side=="after"?this.getDOMAfter().appendChild(s.dom):this.dom.appendChild(s.dom);this.gutters=r}return n}destroy(){for(let i of this.gutters)i.destroy();this.dom.remove(),this.domAfter&&this.domAfter.remove()}},{provide:i=>M.scrollMargins.of(e=>{let t=e.plugin(i);if(!t||t.gutters.length==0||!t.fixed)return null;let n=t.dom.offsetWidth*e.scaleX,r=t.domAfter?t.domAfter.offsetWidth*e.scaleX:0;return e.textDirection==re.LTR?{left:n,right:r}:{right:n,left:r}})});function X0(i){return Array.isArray(i)?i:[i]}function Vu(i,e,t){for(;i.value&&i.from<=t;)i.from==t&&e.push(i.value),i.next()}var Yu=class{constructor(e,t,n){this.gutter=e,this.height=n,this.i=0,this.cursor=G.iter(e.markers,t.from)}addElement(e,t,n){let{gutter:r}=this,s=(t.top-this.height)/e.scaleY,o=t.height/e.scaleY;if(this.i==r.elements.length){let l=new Fa(e,o,s,n);r.elements.push(l),r.dom.appendChild(l.dom)}else r.elements[this.i].update(e,o,s,n);this.height=t.bottom,this.i++}line(e,t,n){let r=[];Vu(this.cursor,r,t.from),n.length&&(r=r.concat(n));let s=this.gutter.config.lineMarker(e,t,r);s&&r.unshift(s);let o=this.gutter;r.length==0&&!o.config.renderEmptyElements||this.addElement(e,t,r)}widget(e,t){let n=this.gutter.config.widgetMarker(e,t.widget,t),r=n?[n]:null;for(let s of e.state.facet(MP)){let o=s(e,t.widget,t);o&&(r||(r=[])).push(o)}r&&this.addElement(e,t,r)}finish(){let e=this.gutter;for(;e.elements.length>this.i;){let t=e.elements.pop();e.dom.removeChild(t.dom),t.destroy()}}},Ga=class{constructor(e,t){this.view=e,this.config=t,this.elements=[],this.spacer=null,this.dom=document.createElement("div"),this.dom.className="cm-gutter"+(this.config.class?" "+this.config.class:"");for(let n in t.domEventHandlers)this.dom.addEventListener(n,r=>{let s=r.target,o;if(s!=this.dom&&this.dom.contains(s)){for(;s.parentNode!=this.dom;)s=s.parentNode;let a=s.getBoundingClientRect();o=(a.top+a.bottom)/2}else o=r.clientY;let l=e.lineBlockAtHeight(o-e.documentTop);t.domEventHandlers[n](e,l,r)&&r.preventDefault()});this.markers=X0(t.markers(e)),t.initialSpacer&&(this.spacer=new Fa(e,0,0,[t.initialSpacer(e)]),this.dom.appendChild(this.spacer.dom),this.spacer.dom.style.cssText+="visibility: hidden; pointer-events: none")}update(e){let t=this.markers;if(this.markers=X0(this.config.markers(e.view)),this.spacer&&this.config.updateSpacer){let r=this.config.updateSpacer(this.spacer.markers[0],e);r!=this.spacer.markers[0]&&this.spacer.update(e.view,0,0,[r])}let n=e.view.viewport;return!G.eq(this.markers,t,n.from,n.to)||(this.config.lineMarkerChange?this.config.lineMarkerChange(e):!1)}destroy(){for(let e of this.elements)e.destroy()}},Fa=class{constructor(e,t,n,r){this.height=-1,this.above=0,this.markers=[],this.dom=document.createElement("div"),this.dom.className="cm-gutterElement",this.update(e,t,n,r)}update(e,t,n,r){this.height!=t&&(this.height=t,this.dom.style.height=t+"px"),this.above!=n&&(this.dom.style.marginTop=(this.above=n)?n+"px":""),EP(this.markers,r)||this.setMarkers(e,r)}setMarkers(e,t){let n="cm-gutterElement",r=this.dom.firstChild;for(let s=0,o=0;;){let l=o,a=ss(l,a,c)||o(l,a,c):o}return n}})}}),Oo=class extends yt{constructor(e){super(),this.number=e}eq(e){return this.number==e.number}toDOM(){return document.createTextNode(this.number)}};function Gf(i,e){return i.state.facet(Nr).formatNumber(e,i.state)}var LP=po.compute([Nr],i=>({class:"cm-lineNumbers",renderEmptyElements:!1,markers(e){return e.state.facet(ZP)},lineMarker(e,t,n){return n.some(r=>r.toDOM)?null:new Oo(Gf(e,e.state.doc.lineAt(t.from).number))},widgetMarker:(e,t,n)=>{for(let r of e.state.facet(_P)){let s=r(e,t,n);if(s)return s}return null},lineMarkerChange:e=>e.startState.facet(Nr)!=e.state.facet(Nr),initialSpacer(e){return new Oo(Gf(e,E0(e.state.doc.lines)))},updateSpacer(e,t){let n=Gf(t.view,E0(t.view.state.doc.lines));return n==e.number?e:new Oo(n)},domEventHandlers:i.facet(Nr).domEventHandlers,side:"before"}));function G1(i={}){return[Nr.of(i),q1(),LP]}function E0(i){let e=9;for(;e{let e=[],t=-1;for(let n of i.selection.ranges){let r=i.doc.lineAt(n.head).from;r>t&&(t=r,e.push(DP.range(r)))}return G.of(e)});function F1(){return jP}var tc=class i{constructor(e,t){this.yanchor=e,this.yhead=t}toJSON(){return{yanchor:ea(this.yanchor),yhead:ea(this.yhead)}}static fromJSON(e){return new i(bi(e.yanchor),bi(e.yhead))}};var ic=class{constructor(e,t){this.ytext=e,this.awareness=t}toYPos(e,t=0){return Xr(this.ytext,e,t)}fromYPos(e){let t=Er(bi(e),this.ytext.doc);if(t==null||t.type!==this.ytext)throw new Error("[y-codemirror] The position you want to retrieve was created by a different document");return{pos:t.index,assoc:t.assoc}}toYRange(e){let t=e.assoc,n=this.toYPos(e.anchor,t),r=this.toYPos(e.head,t);return new tc(n,r)}fromYRange(e){let t=this.fromYPos(e.yanchor),n=this.fromYPos(e.yhead);return t.pos===n.pos?v.cursor(n.pos,n.assoc):v.range(t.pos,n.pos)}},Yn=R.define({combine(i){return i[i.length-1]}}),wo=we.define(),nd=class{constructor(e){this.view=e,this.conf=e.state.facet(Yn),this._observer=(t,n)=>{if(n.origin!==this.conf){let r=t.delta,s=[],o=0;for(let l=0;l0&&e.transactions[0].annotation(wo)===this.conf)return;let t=this.conf.ytext;t.doc.transact(()=>{let n=0;e.changes.iterChanges((r,s,o,l,a)=>{let c=a.sliceString(0,a.length,` +`);r!==s&&t.delete(r+n,s-r),c.length>0&&t.insert(r+n,c),n+=c.length-(s-r)})},this.conf)}destroy(){this._ytext.unobserve(this._observer)}},H1=se.fromClass(nd);var K1=M.baseTheme({".cm-ySelection":{},".cm-yLineSelection":{padding:0,margin:"0px 2px 0px 4px"},".cm-ySelectionCaret":{position:"relative",borderLeft:"1px solid black",borderRight:"1px solid black",marginLeft:"-1px",marginRight:"-1px",boxSizing:"border-box",display:"inline"},".cm-ySelectionCaretDot":{borderRadius:"50%",position:"absolute",width:".4em",height:".4em",top:"-.2em",left:"-.2em",backgroundColor:"inherit",transition:"transform .3s ease-in-out",boxSizing:"border-box"},".cm-ySelectionCaret:hover > .cm-ySelectionCaretDot":{transformOrigin:"bottom center",transform:"scale(0)"},".cm-ySelectionInfo":{position:"absolute",top:"-1.05em",left:"-1px",fontSize:".75em",fontFamily:"serif",fontStyle:"normal",fontWeight:"normal",lineHeight:"normal",userSelect:"none",color:"white",paddingLeft:"2px",paddingRight:"2px",zIndex:101,transition:"opacity .3s ease-in-out",backgroundColor:"inherit",opacity:0,transitionDelay:"0s",whiteSpace:"nowrap"},".cm-ySelectionCaret:hover > .cm-ySelectionInfo":{opacity:1,transitionDelay:"0s"}}),IP=we.define(),rd=class extends De{constructor(e,t){super(),this.color=e,this.name=t}toDOM(){return Es("span",[Ge("class","cm-ySelectionCaret"),Ge("style",`background-color: ${this.color}; border-color: ${this.color}`)],[vr("\u2060"),Es("div",[Ge("class","cm-ySelectionCaretDot")]),vr("\u2060"),Es("div",[Ge("class","cm-ySelectionInfo")],[vr(this.name)]),vr("\u2060")])}eq(e){return e.color===this.color}compare(e){return e.color===this.color}updateDOM(){return!1}get estimatedHeight(){return-1}ignoreEvent(){return!0}},sd=class{constructor(e){this.conf=e.state.facet(Yn),this._listener=({added:t,updated:n,removed:r},s,o)=>{t.concat(n).concat(r).findIndex(a=>a!==this.conf.awareness.doc.clientID)>=0&&e.dispatch({annotations:[IP.of([])]})},this._awareness=this.conf.awareness,this._awareness.on("change",this._listener),this.decorations=G.of([])}destroy(){this._awareness.off("change",this._listener)}update(e){let t=this.conf.ytext,n=t.doc,r=this.conf.awareness,s=[],o=this.conf.awareness.getLocalState();if(o!=null){let l=e.view.hasFocus&&e.view.dom.ownerDocument.hasFocus(),a=l?e.state.selection.main:null,c=o.cursor==null?null:bi(o.cursor.anchor),h=o.cursor==null?null:bi(o.cursor.head);if(a!=null){let f=Xr(t,a.anchor),u=Xr(t,a.head);(o.cursor==null||!ta(c,f)||!ta(h,u))&&r.setLocalStateField("cursor",{anchor:f,head:u})}else o.cursor!=null&&l&&r.setLocalStateField("cursor",null)}r.getStates().forEach((l,a)=>{if(a===r.doc.clientID)return;let c=l.cursor;if(c==null||c.anchor==null||c.head==null)return;let h=Er(c.anchor,n),f=Er(c.head,n);if(h==null||f==null||h.type!==t||f.type!==t)return;let{color:u="#30bced",name:d="Anonymous"}=l.user||{},p=l.user&&l.user.colorLight||u+"33",O=or(h.index,f.index),g=Lt(h.index,f.index),b=e.view.state.doc.lineAt(O),w=e.view.state.doc.lineAt(g);if(b.number===w.number)s.push({from:O,to:g,value:L.mark({attributes:{style:`background-color: ${p}`},class:"cm-ySelection"})});else{s.push({from:O,to:b.from+b.length,value:L.mark({attributes:{style:`background-color: ${p}`},class:"cm-ySelection"})}),s.push({from:w.from,to:g,value:L.mark({attributes:{style:`background-color: ${p}`},class:"cm-ySelection"})});for(let x=b.number+1;x0?-1:1,block:!1,widget:new rd(u,d)})})}),this.decorations=L.set(s,!0)}},J1=se.fromClass(sd,{decorations:i=>i.decorations});var nc=class{constructor(e){this.undoManager=e}addTrackedOrigin(e){this.undoManager.addTrackedOrigin(e)}removeTrackedOrigin(e){this.undoManager.removeTrackedOrigin(e)}undo(){return this.undoManager.undo()!=null}redo(){return this.undoManager.redo()!=null}},ko=R.define({combine(i){return i[i.length-1]}}),CX=we.define(),od=class{constructor(e){this.view=e,this.conf=e.state.facet(ko),this._undoManager=this.conf.undoManager,this.syncConf=e.state.facet(Yn),this._beforeChangeSelection=null,this._onStackItemAdded=({stackItem:t,changedParentTypes:n})=>{n.has(this.syncConf.ytext)&&this._beforeChangeSelection&&!t.meta.has(this)&&t.meta.set(this,this._beforeChangeSelection)},this._onStackItemPopped=({stackItem:t})=>{let n=t.meta.get(this);if(n){let r=this.syncConf.fromYRange(n);e.dispatch(e.state.update({selection:r,effects:[M.scrollIntoView(r)]})),this._storeSelection()}},this._storeSelection=()=>{this._beforeChangeSelection=this.syncConf.toYRange(this.view.state.selection.main)},this._undoManager.on("stack-item-added",this._onStackItemAdded),this._undoManager.on("stack-item-popped",this._onStackItemPopped),this._undoManager.addTrackedOrigin(this.syncConf)}update(e){e.selectionSet&&(e.transactions.length===0||e.transactions[0].annotation(wo)!==this.syncConf)&&this._storeSelection()}destroy(){this._undoManager.off("stack-item-added",this._onStackItemAdded),this._undoManager.off("stack-item-popped",this._onStackItemPopped),this._undoManager.removeTrackedOrigin(this.syncConf)}},eb=se.fromClass(od),ld=({state:i,dispatch:e})=>i.facet(ko).undo()||!0,rc=({state:i,dispatch:e})=>i.facet(ko).redo()||!0;var tb=[{key:"Mod-z",run:ld,preventDefault:!0},{key:"Mod-y",mac:"Mod-Shift-z",run:rc,preventDefault:!0},{key:"Mod-Shift-z",run:rc,preventDefault:!0}];var UP=(i,e,{undoManager:t=new Bs(i)}={})=>{let n=new ic(i,e),r=[Yn.of(n),H1];return e&&r.push(K1,J1),t!==!1&&r.push(ko.of(new nc(t)),eb,M.domEventHandlers({beforeinput(s,o){return s.inputType==="historyUndo"?ld(o):s.inputType==="historyRedo"?rc(o):!1}})),r};var BP=0,ht=class{constructor(e,t){this.from=e,this.to=t}},U=class{constructor(e={}){this.id=BP++,this.perNode=!!e.perNode,this.deserialize=e.deserialize||(()=>{throw new Error("This node type doesn't define a deserialize function")}),this.combine=e.combine||null}add(e){if(this.perNode)throw new RangeError("Can't add per-node props to node types");return typeof e!="function"&&(e=Oe.match(e)),t=>{let n=e(t);return n===void 0?null:[this,n]}}};U.closedBy=new U({deserialize:i=>i.split(" ")});U.openedBy=new U({deserialize:i=>i.split(" ")});U.group=new U({deserialize:i=>i.split(" ")});U.isolate=new U({deserialize:i=>{if(i&&i!="rtl"&&i!="ltr"&&i!="auto")throw new RangeError("Invalid value for isolate: "+i);return i||"auto"}});U.contextHash=new U({perNode:!0});U.lookAhead=new U({perNode:!0});U.mounted=new U({perNode:!0});var Yi=class{constructor(e,t,n,r=!1){this.tree=e,this.overlay=t,this.parser=n,this.bracketed=r}static get(e){return e&&e.props&&e.props[U.mounted.id]}},VP=Object.create(null),Oe=class i{constructor(e,t,n,r=0){this.name=e,this.props=t,this.id=n,this.flags=r}static define(e){let t=e.props&&e.props.length?Object.create(null):VP,n=(e.top?1:0)|(e.skipped?2:0)|(e.error?4:0)|(e.name==null?8:0),r=new i(e.name||"",t,e.id,n);if(e.props){for(let s of e.props)if(Array.isArray(s)||(s=s(r)),s){if(s[0].perNode)throw new RangeError("Can't store a per-node prop on a node type");t[s[0].id]=s[1]}}return r}prop(e){return this.props[e.id]}get isTop(){return(this.flags&1)>0}get isSkipped(){return(this.flags&2)>0}get isError(){return(this.flags&4)>0}get isAnonymous(){return(this.flags&8)>0}is(e){if(typeof e=="string"){if(this.name==e)return!0;let t=this.prop(U.group);return t?t.indexOf(e)>-1:!1}return this.id==e}static match(e){let t=Object.create(null);for(let n in e)for(let r of n.split(" "))t[r]=e[n];return n=>{for(let r=n.prop(U.group),s=-1;s<(r?r.length:0);s++){let o=t[s<0?n.name:r[s]];if(o)return o}}}};Oe.none=new Oe("",Object.create(null),0,8);var zi=class i{constructor(e){this.types=e;for(let t=0;t0;for(let a=this.cursor(o|F.IncludeAnonymous);;){let c=!1;if(a.from<=s&&a.to>=r&&(!l&&a.type.isAnonymous||t(a)!==!1)){if(a.firstChild())continue;c=!0}for(;c&&n&&(l||!a.type.isAnonymous)&&n(a),!a.nextSibling();){if(!a.parent())return;c=!0}}}prop(e){return e.perNode?this.props?this.props[e.id]:void 0:this.type.prop(e)}get propValues(){let e=[];if(this.props)for(let t in this.props)e.push([+t,this.props[t]]);return e}balance(e={}){return this.children.length<=8?this:bd(Oe.none,this.children,this.positions,0,this.children.length,0,this.length,(t,n,r)=>new i(this.type,t,n,r,this.propValues),e.makeTree||((t,n,r)=>new i(Oe.none,t,n,r)))}static build(e){return zP(e)}};J.empty=new J(Oe.none,[],[],0);var ad=class i{constructor(e,t){this.buffer=e,this.index=t}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}get pos(){return this.index}next(){this.index-=4}fork(){return new i(this.buffer,this.index)}},Wi=class i{constructor(e,t,n){this.buffer=e,this.length=t,this.set=n}get type(){return Oe.none}toString(){let e=[];for(let t=0;t0));a=o[a+3]);return l}slice(e,t,n){let r=this.buffer,s=new Uint16Array(t-e),o=0;for(let l=e,a=0;l=e&&te;case 1:return t<=e&&n>e;case 2:return n>e;case 4:return!0}}function Qo(i,e,t,n){for(var r;i.from==i.to||(t<1?i.from>=e:i.from>e)||(t>-1?i.to<=e:i.to0?l.length:-1;e!=c;e+=t){let h=l[e],f=a[e]+o.from,u;if(!(!(s&F.EnterBracketed&&h instanceof J&&(u=Yi.get(h))&&!u.overlay&&u.bracketed&&n>=f&&n<=f+h.length)&&!cb(r,n,f,f+h.length))){if(h instanceof Wi){if(s&F.ExcludeBuffers)continue;let d=h.findChild(0,h.buffer.length,t,n-f,r);if(d>-1)return new zn(new hd(o,h,e,f),null,d)}else if(s&F.IncludeAnonymous||!h.type.isAnonymous||md(h)){let d;if(!(s&F.IgnoreMounts)&&(d=Yi.get(h))&&!d.overlay)return new i(d.tree,f,e,o);let p=new i(h,f,e,o);return s&F.IncludeAnonymous||!p.type.isAnonymous?p:p.nextChild(t<0?h.children.length-1:0,t,n,r,s)}}}if(s&F.IncludeAnonymous||!o.type.isAnonymous||(o.index>=0?e=o.index+t:e=t<0?-1:o._parent._tree.children.length,o=o._parent,!o))return null}}get firstChild(){return this.nextChild(0,1,0,4)}get lastChild(){return this.nextChild(this._tree.children.length-1,-1,0,4)}childAfter(e){return this.nextChild(0,1,e,2)}childBefore(e){return this.nextChild(this._tree.children.length-1,-1,e,-2)}prop(e){return this._tree.prop(e)}enter(e,t,n=0){let r;if(!(n&F.IgnoreOverlays)&&(r=Yi.get(this._tree))&&r.overlay){let s=e-this.from,o=n&F.EnterBracketed&&r.bracketed;for(let{from:l,to:a}of r.overlay)if((t>0||o?l<=s:l=s:a>s))return new i(r.tree,r.overlay[0].from+this.from,-1,this)}return this.nextChild(0,1,e,t,n)}nextSignificantParent(){let e=this;for(;e.type.isAnonymous&&e._parent;)e=e._parent;return e}get parent(){return this._parent?this._parent.nextSignificantParent():null}get nextSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index+1,1,0,4):null}get prevSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index-1,-1,0,4):null}get tree(){return this._tree}toTree(){return this._tree}toString(){return this._tree.toString()}};function nb(i,e,t,n){let r=i.cursor(),s=[];if(!r.firstChild())return s;if(t!=null){for(let o=!1;!o;)if(o=r.type.is(t),!r.nextSibling())return s}for(;;){if(n!=null&&r.type.is(n))return s;if(r.type.is(e)&&s.push(r.node),!r.nextSibling())return n==null?s:[]}}function cd(i,e,t=e.length-1){for(let n=i;t>=0;n=n.parent){if(!n)return!1;if(!n.type.isAnonymous){if(e[t]&&e[t]!=n.name)return!1;t--}}return!0}var hd=class{constructor(e,t,n,r){this.parent=e,this.buffer=t,this.index=n,this.start=r}},zn=class i extends lc{get name(){return this.type.name}get from(){return this.context.start+this.context.buffer.buffer[this.index+1]}get to(){return this.context.start+this.context.buffer.buffer[this.index+2]}constructor(e,t,n){super(),this.context=e,this._parent=t,this.index=n,this.type=e.buffer.set.types[e.buffer.buffer[n]]}child(e,t,n){let{buffer:r}=this.context,s=r.findChild(this.index+4,r.buffer[this.index+3],e,t-this.context.start,n);return s<0?null:new i(this.context,this,s)}get firstChild(){return this.child(1,0,4)}get lastChild(){return this.child(-1,0,4)}childAfter(e){return this.child(1,e,2)}childBefore(e){return this.child(-1,e,-2)}prop(e){return this.type.prop(e)}enter(e,t,n=0){if(n&F.ExcludeBuffers)return null;let{buffer:r}=this.context,s=r.findChild(this.index+4,r.buffer[this.index+3],t>0?1:-1,e-this.context.start,t);return s<0?null:new i(this.context,this,s)}get parent(){return this._parent||this.context.parent.nextSignificantParent()}externalSibling(e){return this._parent?null:this.context.parent.nextChild(this.context.index+e,e,0,4)}get nextSibling(){let{buffer:e}=this.context,t=e.buffer[this.index+3];return t<(this._parent?e.buffer[this._parent.index+3]:e.buffer.length)?new i(this.context,this._parent,t):this.externalSibling(1)}get prevSibling(){let{buffer:e}=this.context,t=this._parent?this._parent.index+4:0;return this.index==t?this.externalSibling(-1):new i(this.context,this._parent,e.findChild(t,this.index,-1,0,4))}get tree(){return null}toTree(){let e=[],t=[],{buffer:n}=this.context,r=this.index+4,s=n.buffer[this.index+3];if(s>r){let o=n.buffer[this.index+1];e.push(n.slice(r,s,o)),t.push(0)}return new J(this.type,e,t,this.to-this.from)}toString(){return this.context.buffer.childString(this.index)}};function hb(i){if(!i.length)return null;let e=0,t=i[0];for(let s=1;st.from||o.to=e){let l=new ft(o.tree,o.overlay[0].from+s.from,-1,s);(r||(r=[n])).push(Qo(l,e,t,!1))}}return r?hb(r):n}var ns=class{get name(){return this.type.name}constructor(e,t=0){if(this.buffer=null,this.stack=[],this.index=0,this.bufferNode=null,this.mode=t&~F.EnterBracketed,e instanceof ft)this.yieldNode(e);else{this._tree=e.context.parent,this.buffer=e.context;for(let n=e._parent;n;n=n._parent)this.stack.unshift(n.index);this.bufferNode=e,this.yieldBuf(e.index)}}yieldNode(e){return e?(this._tree=e,this.type=e.type,this.from=e.from,this.to=e.to,!0):!1}yieldBuf(e,t){this.index=e;let{start:n,buffer:r}=this.buffer;return this.type=t||r.set.types[r.buffer[e]],this.from=n+r.buffer[e+1],this.to=n+r.buffer[e+2],!0}yield(e){return e?e instanceof ft?(this.buffer=null,this.yieldNode(e)):(this.buffer=e.context,this.yieldBuf(e.index,e.type)):!1}toString(){return this.buffer?this.buffer.buffer.childString(this.index):this._tree.toString()}enterChild(e,t,n){if(!this.buffer)return this.yield(this._tree.nextChild(e<0?this._tree._tree.children.length-1:0,e,t,n,this.mode));let{buffer:r}=this.buffer,s=r.findChild(this.index+4,r.buffer[this.index+3],e,t-this.buffer.start,n);return s<0?!1:(this.stack.push(this.index),this.yieldBuf(s))}firstChild(){return this.enterChild(1,0,4)}lastChild(){return this.enterChild(-1,0,4)}childAfter(e){return this.enterChild(1,e,2)}childBefore(e){return this.enterChild(-1,e,-2)}enter(e,t,n=this.mode){return this.buffer?n&F.ExcludeBuffers?!1:this.enterChild(1,e,t):this.yield(this._tree.enter(e,t,n))}parent(){if(!this.buffer)return this.yieldNode(this.mode&F.IncludeAnonymous?this._tree._parent:this._tree.parent);if(this.stack.length)return this.yieldBuf(this.stack.pop());let e=this.mode&F.IncludeAnonymous?this.buffer.parent:this.buffer.parent.nextSignificantParent();return this.buffer=null,this.yieldNode(e)}sibling(e){if(!this.buffer)return this._tree._parent?this.yield(this._tree.index<0?null:this._tree._parent.nextChild(this._tree.index+e,e,0,4,this.mode)):!1;let{buffer:t}=this.buffer,n=this.stack.length-1;if(e<0){let r=n<0?0:this.stack[n]+4;if(this.index!=r)return this.yieldBuf(t.findChild(r,this.index,-1,0,4))}else{let r=t.buffer[this.index+3];if(r<(n<0?t.buffer.length:t.buffer[this.stack[n]+3]))return this.yieldBuf(r)}return n<0?this.yield(this.buffer.parent.nextChild(this.buffer.index+e,e,0,4,this.mode)):!1}nextSibling(){return this.sibling(1)}prevSibling(){return this.sibling(-1)}atLastNode(e){let t,n,{buffer:r}=this;if(r){if(e>0){if(this.index-1)for(let s=t+e,o=e<0?-1:n._tree.children.length;s!=o;s+=e){let l=n._tree.children[s];if(this.mode&F.IncludeAnonymous||l instanceof Wi||!l.type.isAnonymous||md(l))return!1}return!0}move(e,t){if(t&&this.enterChild(e,0,4))return!0;for(;;){if(this.sibling(e))return!0;if(this.atLastNode(e)||!this.parent())return!1}}next(e=!0){return this.move(1,e)}prev(e=!0){return this.move(-1,e)}moveTo(e,t=0){for(;(this.from==this.to||(t<1?this.from>=e:this.from>e)||(t>-1?this.to<=e:this.to=0;){for(let o=e;o;o=o._parent)if(o.index==r){if(r==this.index)return o;t=o,n=s+1;break e}r=this.stack[--s]}for(let r=n;r=0;s--){if(s<0)return cd(this._tree,e,r);let o=n[t.buffer[this.stack[s]]];if(!o.isAnonymous){if(e[r]&&e[r]!=o.name)return!1;r--}}return!0}};function md(i){return i.children.some(e=>e instanceof Wi||!e.type.isAnonymous||md(e))}function zP(i){var e;let{buffer:t,nodeSet:n,maxBufferLength:r=1024,reused:s=[],minRepeatType:o=n.types.length}=i,l=Array.isArray(t)?new ad(t,t.length):t,a=n.types,c=0,h=0;function f(C,T,y,Q,S,$){let{id:k,start:P,end:E,size:D}=l,V=h,N=c;if(D<0)if(l.next(),D==-1){let Gt=s[k];y.push(Gt),Q.push(P-C);return}else if(D==-3){c=k;return}else if(D==-4){h=k;return}else throw new RangeError(`Unrecognized record size: ${D}`);let j=a[k],H,ie,je=P-C;if(E-P<=r&&(ie=g(l.pos-T,S))){let Gt=new Uint16Array(ie.size-ie.skip),Ne=l.pos-ie.size,ye=Gt.length;for(;l.pos>Ne;)ye=b(ie.start,Gt,ye);H=new Wi(Gt,E-ie.start,n),je=ie.start-C}else{let Gt=l.pos-D;l.next();let Ne=[],ye=[],Ft=k>=o?k:-1,rr=0,sl=E;for(;l.pos>Gt;)Ft>=0&&l.id==Ft&&l.size>=0?(l.end<=sl-r&&(p(Ne,ye,P,rr,l.end,sl,Ft,V,N),rr=Ne.length,sl=l.end),l.next()):$>2500?u(P,Gt,Ne,ye):f(P,Gt,Ne,ye,Ft,$+1);if(Ft>=0&&rr>0&&rr-1&&rr>0){let SO=d(j,N);H=bd(j,Ne,ye,0,Ne.length,0,E-P,SO,SO)}else H=O(j,Ne,ye,E-P,V-E,N)}y.push(H),Q.push(je)}function u(C,T,y,Q){let S=[],$=0,k=-1;for(;l.pos>T;){let{id:P,start:E,end:D,size:V}=l;if(V>4)l.next();else{if(k>-1&&E=0;D-=3)P[V++]=S[D],P[V++]=S[D+1]-E,P[V++]=S[D+2]-E,P[V++]=V;y.push(new Wi(P,S[2]-E,n)),Q.push(E-C)}}function d(C,T){return(y,Q,S)=>{let $=0,k=y.length-1,P,E;if(k>=0&&(P=y[k])instanceof J){if(!k&&P.type==C&&P.length==S)return P;(E=P.prop(U.lookAhead))&&($=Q[k]+P.length+E)}return O(C,y,Q,S,$,T)}}function p(C,T,y,Q,S,$,k,P,E){let D=[],V=[];for(;C.length>Q;)D.push(C.pop()),V.push(T.pop()+y-S);C.push(O(n.types[k],D,V,$-S,P-$,E)),T.push(S-y)}function O(C,T,y,Q,S,$,k){if($){let P=[U.contextHash,$];k=k?[P].concat(k):[P]}if(S>25){let P=[U.lookAhead,S];k=k?[P].concat(k):[P]}return new J(C,T,y,Q,k)}function g(C,T){let y=l.fork(),Q=0,S=0,$=0,k=y.end-r,P={size:0,start:0,skip:0};e:for(let E=y.pos-C;y.pos>E;){let D=y.size;if(y.id==T&&D>=0){P.size=Q,P.start=S,P.skip=$,$+=4,Q+=4,y.next();continue}let V=y.pos-D;if(D<0||V=o?4:0,j=y.start;for(y.next();y.pos>V;){if(y.size<0)if(y.size==-3||y.size==-4)N+=4;else break e;else y.id>=o&&(N+=4);y.next()}S=j,Q+=D,$+=N}return(T<0||Q==C)&&(P.size=Q,P.start=S,P.skip=$),P.size>4?P:void 0}function b(C,T,y){let{id:Q,start:S,end:$,size:k}=l;if(l.next(),k>=0&&Q4){let E=l.pos-(k-4);for(;l.pos>E;)y=b(C,T,y)}T[--y]=P,T[--y]=$-C,T[--y]=S-C,T[--y]=Q}else k==-3?c=Q:k==-4&&(h=Q);return y}let w=[],x=[];for(;l.pos>0;)f(i.start||0,i.bufferStart||0,w,x,-1,0);let X=(e=i.length)!==null&&e!==void 0?e:w.length?x[0]+w[0].length:0;return new J(a[i.topID],w.reverse(),x.reverse(),X)}var rb=new WeakMap;function oc(i,e){if(!i.isAnonymous||e instanceof Wi||e.type!=i)return 1;let t=rb.get(e);if(t==null){t=1;for(let n of e.children){if(n.type!=i||!(n instanceof J)){t=1;break}t+=oc(i,n)}rb.set(e,t)}return t}function bd(i,e,t,n,r,s,o,l,a){let c=0;for(let p=n;p=h)break;T+=y}if(x==X+1){if(T>h){let y=p[X];d(y.children,y.positions,0,y.children.length,O[X]+w);continue}f.push(p[X])}else{let y=O[x-1]+p[x-1].length-C;f.push(bd(i,p,O,X,x,C,y,null,a))}u.push(C+w-s)}}return d(e,t,n,r,0),(l||a)(f,u,o)}var rs=class{constructor(){this.map=new WeakMap}setBuffer(e,t,n){let r=this.map.get(e);r||this.map.set(e,r=new Map),r.set(t,n)}getBuffer(e,t){let n=this.map.get(e);return n&&n.get(t)}set(e,t){e instanceof zn?this.setBuffer(e.context.buffer,e.index,t):e instanceof ft&&this.map.set(e.tree,t)}get(e){return e instanceof zn?this.getBuffer(e.context.buffer,e.index):e instanceof ft?this.map.get(e.tree):void 0}cursorSet(e,t){e.buffer?this.setBuffer(e.buffer.buffer,e.index,t):this.map.set(e.tree,t)}cursorGet(e){return e.buffer?this.getBuffer(e.buffer.buffer,e.index):this.map.get(e.tree)}},vi=class i{constructor(e,t,n,r,s=!1,o=!1){this.from=e,this.to=t,this.tree=n,this.offset=r,this.open=(s?1:0)|(o?2:0)}get openStart(){return(this.open&1)>0}get openEnd(){return(this.open&2)>0}static addTree(e,t=[],n=!1){let r=[new i(0,e.length,e,0,!1,n)];for(let s of t)s.to>e.length&&r.push(s);return r}static applyChanges(e,t,n=128){if(!t.length)return e;let r=[],s=1,o=e.length?e[0]:null;for(let l=0,a=0,c=0;;l++){let h=l=n)for(;o&&o.from=u.from||f<=u.to||c){let d=Math.max(u.from,a)-c,p=Math.min(u.to,f)-c;u=d>=p?null:new i(d,p,u.tree,u.offset+c,l>0,!!h)}if(u&&r.push(u),o.to>f)break;o=snew ht(r.from,r.to)):[new ht(0,0)]:[new ht(0,e.length)],this.createParse(e,t||[],n)}parse(e,t,n){let r=this.startParse(e,t,n);for(;;){let s=r.advance();if(s)return s}}},ud=class{constructor(e){this.string=e}get length(){return this.string.length}chunk(e){return this.string.slice(e)}get lineChunks(){return!1}read(e,t){return this.string.slice(e,t)}};function hc(i){return(e,t,n,r)=>new Od(e,i,t,n,r)}var ac=class{constructor(e,t,n,r,s,o){this.parser=e,this.parse=t,this.overlay=n,this.bracketed=r,this.target=s,this.from=o}};function sb(i){if(!i.length||i.some(e=>e.from>=e.to))throw new RangeError("Invalid inner parse ranges given: "+JSON.stringify(i))}var dd=class{constructor(e,t,n,r,s,o,l,a){this.parser=e,this.predicate=t,this.mounts=n,this.index=r,this.start=s,this.bracketed=o,this.target=l,this.prev=a,this.depth=0,this.ranges=[]}},pd=new U({perNode:!0}),Od=class{constructor(e,t,n,r,s){this.nest=t,this.input=n,this.fragments=r,this.ranges=s,this.inner=[],this.innerDone=0,this.baseTree=null,this.stoppedAt=null,this.baseParse=e}advance(){if(this.baseParse){let n=this.baseParse.advance();if(!n)return null;if(this.baseParse=null,this.baseTree=n,this.startInner(),this.stoppedAt!=null)for(let r of this.inner)r.parse.stopAt(this.stoppedAt)}if(this.innerDone==this.inner.length){let n=this.baseTree;return this.stoppedAt!=null&&(n=new J(n.type,n.children,n.positions,n.length,n.propValues.concat([[pd,this.stoppedAt]]))),n}let e=this.inner[this.innerDone],t=e.parse.advance();if(t){this.innerDone++;let n=Object.assign(Object.create(null),e.target.props);n[U.mounted.id]=new Yi(t,e.overlay,e.parser,e.bracketed),e.target.props=n}return null}get parsedPos(){if(this.baseParse)return 0;let e=this.input.length;for(let t=this.innerDone;t=this.stoppedAt)l=!1;else if(e.hasNode(r)){if(t){let c=t.mounts.find(h=>h.frag.from<=r.from&&h.frag.to>=r.to&&h.mount.overlay);if(c)for(let h of c.mount.overlay){let f=h.from+c.pos,u=h.to+c.pos;f>=r.from&&u<=r.to&&!t.ranges.some(d=>d.fromf)&&t.ranges.push({from:f,to:u})}}l=!1}else if(n&&(o=WP(n.ranges,r.from,r.to)))l=o!=2;else if(!r.type.isAnonymous&&(s=this.nest(r,this.input))&&(r.fromnew ht(f.from-r.from,f.to-r.from)):null,!!s.bracketed,r.tree,h.length?h[0].from:r.from)),s.overlay?h.length&&(n={ranges:h,depth:0,prev:n}):l=!1}}else if(t&&(a=t.predicate(r))&&(a===!0&&(a=new ht(r.from,r.to)),a.from=0&&t.ranges[c].to==a.from?t.ranges[c]={from:t.ranges[c].from,to:a.to}:t.ranges.push(a)}if(l&&r.firstChild())t&&t.depth++,n&&n.depth++;else for(;!r.nextSibling();){if(!r.parent())break e;if(t&&!--t.depth){let c=lb(this.ranges,t.ranges);c.length&&(sb(c),this.inner.splice(t.index,0,new ac(t.parser,t.parser.startParse(this.input,ab(t.mounts,c),c),t.ranges.map(h=>new ht(h.from-t.start,h.to-t.start)),t.bracketed,t.target,c[0].from))),t=t.prev}n&&!--n.depth&&(n=n.prev)}}}};function WP(i,e,t){for(let n of i){if(n.from>=t)break;if(n.to>e)return n.from<=e&&n.to>=t?2:1}return 0}function ob(i,e,t,n,r,s){if(e=n&&t.enter(n,1,F.IncludeAnonymous|F.IgnoreOverlays|F.ExcludeBuffers)))if(t.to<=n)t.next(!1)||(this.done=!0);else break}hasNode(e){if(this.moveTo(e.from),!this.done&&this.cursor.from+this.offset==e.from&&this.cursor.tree)for(let t=this.cursor.tree;;){if(t==e.tree)return!0;if(t.children.length&&t.positions[0]==0&&t.children[0]instanceof J)t=t.children[0];else break}return!1}},gd=class{constructor(e){var t;if(this.fragments=e,this.curTo=0,this.fragI=0,e.length){let n=this.curFrag=e[0];this.curTo=(t=n.tree.prop(pd))!==null&&t!==void 0?t:n.to,this.inner=new cc(n.tree,-n.offset)}else this.curFrag=this.inner=null}hasNode(e){for(;this.curFrag&&e.from>=this.curTo;)this.nextFrag();return this.curFrag&&this.curFrag.from<=e.from&&this.curTo>=e.to&&this.inner.hasNode(e)}nextFrag(){var e;if(this.fragI++,this.fragI==this.fragments.length)this.curFrag=this.inner=null;else{let t=this.curFrag=this.fragments[this.fragI];this.curTo=(e=t.tree.prop(pd))!==null&&e!==void 0?e:t.to,this.inner=new cc(t.tree,-t.offset)}}findMounts(e,t){var n;let r=[];if(this.inner){this.inner.cursor.moveTo(e,1);for(let s=this.inner.cursor.node;s;s=s.parent){let o=(n=s.tree)===null||n===void 0?void 0:n.prop(U.mounted);if(o&&o.parser==t)for(let l=this.fragI;l=s.to)break;a.tree==this.curFrag.tree&&r.push({frag:a,pos:s.from-a.offset,mount:o})}}}return r}};function lb(i,e){let t=null,n=e;for(let r=1,s=0;r=l)break;a.to<=o||(t||(n=t=e.slice()),a.froml&&t.splice(s+1,0,new ht(l,a.to))):a.to>l?t[s--]=new ht(l,a.to):t.splice(s--,1))}}return n}function qP(i,e,t,n){let r=0,s=0,o=!1,l=!1,a=-1e9,c=[];for(;;){let h=r==i.length?1e9:o?i[r].to:i[r].from,f=s==e.length?1e9:l?e[s].to:e[s].from;if(o!=l){let u=Math.max(a,t),d=Math.min(h,f,n);unew ht(u.from+n,u.to+n)),f=qP(e,h,a,c);for(let u=0,d=a;;u++){let p=u==f.length,O=p?c:f[u].from;if(O>d&&t.push(new vi(d,O,r.tree,-o,s.from>=d||s.openStart,s.to<=O||s.openEnd)),p)break;d=f[u].to}}else t.push(new vi(a,c,r.tree,-o,s.from>=o||s.openStart,s.to<=l||s.openEnd))}return t}var GP=0,St=class i{constructor(e,t,n,r){this.name=e,this.set=t,this.base=n,this.modified=r,this.id=GP++}toString(){let{name:e}=this;for(let t of this.modified)t.name&&(e=`${t.name}(${e})`);return e}static define(e,t){let n=typeof e=="string"?e:"?";if(e instanceof i&&(t=e),t?.base)throw new Error("Can not derive from a modified tag");let r=new i(n,[],null,[]);if(r.set.push(r),t)for(let s of t.set)r.set.push(s);return r}static defineModifier(e){let t=new pc(e);return n=>n.modified.indexOf(t)>-1?n:pc.get(n.base||n,n.modified.concat(t).sort((r,s)=>r.id-s.id))}},FP=0,pc=class i{constructor(e){this.name=e,this.instances=[],this.id=FP++}static get(e,t){if(!t.length)return e;let n=t[0].instances.find(l=>l.base==e&&HP(t,l.modified));if(n)return n;let r=[],s=new St(e.name,r,e,t);for(let l of t)l.instances.push(s);let o=KP(t);for(let l of e.set)if(!l.modified.length)for(let a of o)r.push(i.get(l,a));return s}};function HP(i,e){return i.length==e.length&&i.every((t,n)=>t==e[n])}function KP(i){let e=[[]];for(let t=0;tn.length-t.length)}function Bt(i){let e=Object.create(null);for(let t in i){let n=i[t];Array.isArray(n)||(n=[n]);for(let r of t.split(" "))if(r){let s=[],o=2,l=r;for(let f=0;;){if(l=="..."&&f>0&&f+3==r.length){o=1;break}let u=/^"(?:[^"\\]|\\.)*?"|[^\/!]+/.exec(l);if(!u)throw new RangeError("Invalid path: "+r);if(s.push(u[0]=="*"?"":u[0][0]=='"'?JSON.parse(u[0]):u[0]),f+=u[0].length,f==r.length)break;let d=r[f++];if(f==r.length&&d=="!"){o=0;break}if(d!="/")throw new RangeError("Invalid path: "+r);l=r.slice(f)}let a=s.length-1,c=s[a];if(!c)throw new RangeError("Invalid path: "+r);let h=new Nn(n,o,a>0?s.slice(0,a):null);e[c]=h.sort(e[c])}}return db.add(e)}var db=new U({combine(i,e){let t,n,r;for(;i||e;){if(!i||e&&i.depth<=e.depth?(r=e,e=e.next):(r=i,i=i.next),t&&t.mode==r.mode&&!r.context&&!t.context)continue;let s=new Nn(r.tags,r.mode,r.context);t?t.next=s:n=s,t=s}return n}}),Nn=class{constructor(e,t,n,r){this.tags=e,this.mode=t,this.context=n,this.next=r}get opaque(){return this.mode==0}get inherit(){return this.mode==1}sort(e){return!e||e.depth{let o=r;for(let l of s)for(let a of l.set){let c=t[a.id];if(c){o=o?o+" "+c:c;break}}return o},scope:n}}function JP(i,e){let t=null;for(let n of i){let r=n.style(e);r&&(t=t?t+" "+r:r)}return t}function pb(i,e,t,n=0,r=i.length){let s=new Sd(n,Array.isArray(e)?e:[e],t);s.highlightRange(i.cursor(),n,r,"",s.highlighters),s.flush(r)}var Sd=class{constructor(e,t,n){this.at=e,this.highlighters=t,this.span=n,this.class=""}startSpan(e,t){t!=this.class&&(this.flush(e),e>this.at&&(this.at=e),this.class=t)}flush(e){e>this.at&&this.class&&this.span(this.at,e,this.class)}highlightRange(e,t,n,r,s){let{type:o,from:l,to:a}=e;if(l>=n||a<=t)return;o.isTop&&(s=this.highlighters.filter(d=>!d.scope||d.scope(o)));let c=r,h=eC(e)||Nn.empty,f=JP(s,h.tags);if(f&&(c&&(c+=" "),c+=f,h.mode==1&&(r+=(r?" ":"")+f)),this.startSpan(Math.max(t,l),c),h.opaque)return;let u=e.tree&&e.tree.prop(U.mounted);if(u&&u.overlay){let d=e.node.enter(u.overlay[0].from+l,1),p=this.highlighters.filter(g=>!g.scope||g.scope(u.tree.type)),O=e.firstChild();for(let g=0,b=l;;g++){let w=g=x||!e.nextSibling())););if(!w||x>n)break;b=w.to+l,b>t&&(this.highlightRange(d.cursor(),Math.max(t,w.from+l),Math.min(n,b),"",p),this.startSpan(Math.min(n,b),c))}O&&e.parent()}else if(e.firstChild()){u&&(r="");do if(!(e.to<=t)){if(e.from>=n)break;this.highlightRange(e,t,n,r,s),this.startSpan(Math.min(n,e.to),c)}while(e.nextSibling());e.parent()}}};function eC(i){let e=i.type.prop(db);for(;e&&e.context&&!i.matchContext(e.context);)e=e.next;return e||null}var Z=St.define,fc=Z(),qi=Z(),fb=Z(qi),ub=Z(qi),Gi=Z(),uc=Z(Gi),yd=Z(Gi),hi=Z(),Wn=Z(hi),ai=Z(),ci=Z(),xd=Z(),vo=Z(xd),dc=Z(),m={comment:fc,lineComment:Z(fc),blockComment:Z(fc),docComment:Z(fc),name:qi,variableName:Z(qi),typeName:fb,tagName:Z(fb),propertyName:ub,attributeName:Z(ub),className:Z(qi),labelName:Z(qi),namespace:Z(qi),macroName:Z(qi),literal:Gi,string:uc,docString:Z(uc),character:Z(uc),attributeValue:Z(uc),number:yd,integer:Z(yd),float:Z(yd),bool:Z(Gi),regexp:Z(Gi),escape:Z(Gi),color:Z(Gi),url:Z(Gi),keyword:ai,self:Z(ai),null:Z(ai),atom:Z(ai),unit:Z(ai),modifier:Z(ai),operatorKeyword:Z(ai),controlKeyword:Z(ai),definitionKeyword:Z(ai),moduleKeyword:Z(ai),operator:ci,derefOperator:Z(ci),arithmeticOperator:Z(ci),logicOperator:Z(ci),bitwiseOperator:Z(ci),compareOperator:Z(ci),updateOperator:Z(ci),definitionOperator:Z(ci),typeOperator:Z(ci),controlOperator:Z(ci),punctuation:xd,separator:Z(xd),bracket:vo,angleBracket:Z(vo),squareBracket:Z(vo),paren:Z(vo),brace:Z(vo),content:hi,heading:Wn,heading1:Z(Wn),heading2:Z(Wn),heading3:Z(Wn),heading4:Z(Wn),heading5:Z(Wn),heading6:Z(Wn),contentSeparator:Z(hi),list:Z(hi),quote:Z(hi),emphasis:Z(hi),strong:Z(hi),link:Z(hi),monospace:Z(hi),strikethrough:Z(hi),inserted:Z(),deleted:Z(),changed:Z(),invalid:Z(),meta:dc,documentMeta:Z(dc),annotation:Z(dc),processingInstruction:Z(dc),definition:St.defineModifier("definition"),constant:St.defineModifier("constant"),function:St.defineModifier("function"),standard:St.defineModifier("standard"),local:St.defineModifier("local"),special:St.defineModifier("special")};for(let i in m){let e=m[i];e instanceof St&&(e.name=i)}var _X=wd([{tag:m.link,class:"tok-link"},{tag:m.heading,class:"tok-heading"},{tag:m.emphasis,class:"tok-emphasis"},{tag:m.strong,class:"tok-strong"},{tag:m.keyword,class:"tok-keyword"},{tag:m.atom,class:"tok-atom"},{tag:m.bool,class:"tok-bool"},{tag:m.url,class:"tok-url"},{tag:m.labelName,class:"tok-labelName"},{tag:m.inserted,class:"tok-inserted"},{tag:m.deleted,class:"tok-deleted"},{tag:m.literal,class:"tok-literal"},{tag:m.string,class:"tok-string"},{tag:m.number,class:"tok-number"},{tag:[m.regexp,m.escape,m.special(m.string)],class:"tok-string2"},{tag:m.variableName,class:"tok-variableName"},{tag:m.local(m.variableName),class:"tok-variableName tok-local"},{tag:m.definition(m.variableName),class:"tok-variableName tok-definition"},{tag:m.special(m.variableName),class:"tok-variableName2"},{tag:m.definition(m.propertyName),class:"tok-propertyName tok-definition"},{tag:m.typeName,class:"tok-typeName"},{tag:m.namespace,class:"tok-namespace"},{tag:m.className,class:"tok-className"},{tag:m.macroName,class:"tok-macroName"},{tag:m.propertyName,class:"tok-propertyName"},{tag:m.operator,class:"tok-operator"},{tag:m.comment,class:"tok-comment"},{tag:m.meta,class:"tok-meta"},{tag:m.invalid,class:"tok-invalid"},{tag:m.punctuation,class:"tok-punctuation"}]);var kd,Fi=new U;function Mo(i){return R.define({combine:i?e=>e.concat(i):void 0})}var bc=new U,tt=class{constructor(e,t,n=[],r=""){this.data=e,this.name=r,ne.prototype.hasOwnProperty("tree")||Object.defineProperty(ne.prototype,"tree",{get(){return ee(this)}}),this.parser=t,this.extension=[Ki.of(this),ne.languageData.of((s,o,l)=>{let a=Ob(s,o,l),c=a.type.prop(Fi);if(!c)return[];let h=s.facet(c),f=a.type.prop(bc);if(f){let u=a.resolve(o-a.from,l);for(let d of f)if(d.test(u,s)){let p=s.facet(d.facet);return d.type=="replace"?p:p.concat(h)}}return h})].concat(n)}isActiveAt(e,t,n=-1){return Ob(e,t,n).type.prop(Fi)==this.data}findRegions(e){let t=e.facet(Ki);if(t?.data==this.data)return[{from:0,to:e.doc.length}];if(!t||!t.allowsNesting)return[];let n=[],r=(s,o)=>{if(s.prop(Fi)==this.data){n.push({from:o,to:o+s.length});return}let l=s.prop(U.mounted);if(l){if(l.tree.prop(Fi)==this.data){if(l.overlay)for(let a of l.overlay)n.push({from:a.from+o,to:a.to+o});else n.push({from:o,to:o+s.length});return}else if(l.overlay){let a=n.length;if(r(l.tree,l.overlay[0].from+o),n.length>a)return}}for(let a=0;an.isTop?t:void 0)]}),e.name)}configure(e,t){return new i(this.data,this.parser.configure(e),t||this.name)}get allowsNesting(){return this.parser.hasWrappers()}};function ee(i){let e=i.field(tt.state,!1);return e?e.tree:J.empty}var Pd=class{constructor(e){this.doc=e,this.cursorPos=0,this.string="",this.cursor=e.iter()}get length(){return this.doc.length}syncTo(e){return this.string=this.cursor.next(e-this.cursorPos).value,this.cursorPos=e+this.string.length,this.cursorPos-this.string.length}chunk(e){return this.syncTo(e),this.string}get lineChunks(){return!0}read(e,t){let n=this.cursorPos-this.string.length;return e=this.cursorPos?this.doc.sliceString(e,t):this.string.slice(e-n,t-n)}},$o=null,Co=class i{constructor(e,t,n=[],r,s,o,l,a){this.parser=e,this.state=t,this.fragments=n,this.tree=r,this.treeLen=s,this.viewport=o,this.skipped=l,this.scheduleOn=a,this.parse=null,this.tempSkipped=[]}static create(e,t,n){return new i(e,t,[],J.empty,0,n,[],null)}startParse(){return this.parser.startParse(new Pd(this.state.doc),this.fragments)}work(e,t){return t!=null&&t>=this.state.doc.length&&(t=void 0),this.tree!=J.empty&&this.isDone(t??this.state.doc.length)?(this.takeTree(),!0):this.withContext(()=>{var n;if(typeof e=="number"){let r=Date.now()+e;e=()=>Date.now()>r}for(this.parse||(this.parse=this.startParse()),t!=null&&(this.parse.stoppedAt==null||this.parse.stoppedAt>t)&&t=this.treeLen&&((this.parse.stoppedAt==null||this.parse.stoppedAt>e)&&this.parse.stopAt(e),this.withContext(()=>{for(;!(t=this.parse.advance()););}),this.treeLen=e,this.tree=t,this.fragments=this.withoutTempSkipped(vi.addTree(this.tree,this.fragments,!0)),this.parse=null)}withContext(e){let t=$o;$o=this;try{return e()}finally{$o=t}}withoutTempSkipped(e){for(let t;t=this.tempSkipped.pop();)e=gb(e,t.from,t.to);return e}changes(e,t){let{fragments:n,tree:r,treeLen:s,viewport:o,skipped:l}=this;if(this.takeTree(),!e.empty){let a=[];if(e.iterChangedRanges((c,h,f,u)=>a.push({fromA:c,toA:h,fromB:f,toB:u})),n=vi.applyChanges(n,a),r=J.empty,s=0,o={from:e.mapPos(o.from,-1),to:e.mapPos(o.to,1)},this.skipped.length){l=[];for(let c of this.skipped){let h=e.mapPos(c.from,1),f=e.mapPos(c.to,-1);he.from&&(this.fragments=gb(this.fragments,r,s),this.skipped.splice(n--,1))}return this.skipped.length>=t?!1:(this.reset(),!0)}reset(){this.parse&&(this.takeTree(),this.parse=null)}skipUntilInView(e,t){this.skipped.push({from:e,to:t})}static getSkippingParser(e){return new class extends Ni{createParse(t,n,r){let s=r[0].from,o=r[r.length-1].to;return{parsedPos:s,advance(){let a=$o;if(a){for(let c of r)a.tempSkipped.push(c);e&&(a.scheduleOn=a.scheduleOn?Promise.all([a.scheduleOn,e]):e)}return this.parsedPos=o,new J(Oe.none,[],[],o-s)},stoppedAt:null,stopAt(){}}}}}isDone(e){e=Math.min(e,this.state.doc.length);let t=this.fragments;return this.treeLen>=e&&t.length&&t[0].from==0&&t[0].to>=e}static get(){return $o}};function gb(i,e,t){return vi.applyChanges(i,[{fromA:e,toA:t,fromB:e,toB:t}])}var To=class i{constructor(e){this.context=e,this.tree=e.tree}apply(e){if(!e.docChanged&&this.tree==this.context.tree)return this;let t=this.context.changes(e.changes,e.state),n=this.context.treeLen==e.startState.doc.length?void 0:Math.max(e.changes.mapPos(this.context.treeLen),t.viewport.to);return t.work(20,n)||t.takeTree(),new i(t)}static init(e){let t=Math.min(3e3,e.doc.length),n=Co.create(e.facet(Ki).parser,e,{from:0,to:t});return n.work(20,t)||n.takeTree(),new i(n)}};tt.state=de.define({create:To.init,update(i,e){for(let t of e.effects)if(t.is(tt.setState))return t.value;return e.startState.facet(Ki)!=e.state.facet(Ki)?To.init(e.state):i.apply(e)}});var kb=i=>{let e=setTimeout(()=>i(),500);return()=>clearTimeout(e)};typeof requestIdleCallback<"u"&&(kb=i=>{let e=-1,t=setTimeout(()=>{e=requestIdleCallback(i,{timeout:400})},100);return()=>e<0?clearTimeout(t):cancelIdleCallback(e)});var Qd=typeof navigator<"u"&&(!((kd=navigator.scheduling)===null||kd===void 0)&&kd.isInputPending)?()=>navigator.scheduling.isInputPending():null,tC=se.fromClass(class{constructor(e){this.view=e,this.working=null,this.workScheduled=0,this.chunkEnd=-1,this.chunkBudget=-1,this.work=this.work.bind(this),this.scheduleWork()}update(e){let t=this.view.state.field(tt.state).context;(t.updateViewport(e.view.viewport)||this.view.viewport.to>t.treeLen)&&this.scheduleWork(),(e.docChanged||e.selectionSet)&&(this.view.hasFocus&&(this.chunkBudget+=50),this.scheduleWork()),this.checkAsyncSchedule(t)}scheduleWork(){if(this.working)return;let{state:e}=this.view,t=e.field(tt.state);(t.tree!=t.context.tree||!t.context.isDone(e.doc.length))&&(this.working=kb(this.work))}work(e){this.working=null;let t=Date.now();if(this.chunkEndr+1e3,a=s.context.work(()=>Qd&&Qd()||Date.now()>o,r+(l?0:1e5));this.chunkBudget-=Date.now()-t,(a||this.chunkBudget<=0)&&(s.context.takeTree(),this.view.dispatch({effects:tt.setState.of(new To(s.context))})),this.chunkBudget>0&&!(a&&!l)&&this.scheduleWork(),this.checkAsyncSchedule(s.context)}checkAsyncSchedule(e){e.scheduleOn&&(this.workScheduled++,e.scheduleOn.then(()=>this.scheduleWork()).catch(t=>_e(this.view.state,t)).then(()=>this.workScheduled--),e.scheduleOn=null)}destroy(){this.working&&this.working()}isWorking(){return!!(this.working||this.workScheduled>0)}},{eventHandlers:{focus(){this.scheduleWork()}}}),Ki=R.define({combine(i){return i.length?i[0]:null},enables:i=>[tt.state,tC,M.contentAttributes.compute([i],e=>{let t=e.facet(i);return t&&t.name?{"data-language":t.name}:{}})]}),Yt=class{constructor(e,t=[]){this.language=e,this.support=t,this.extension=[e,t]}},Ao=class i{constructor(e,t,n,r,s,o=void 0){this.name=e,this.alias=t,this.extensions=n,this.filename=r,this.loadFunc=s,this.support=o,this.loading=null}load(){return this.loading||(this.loading=this.loadFunc().then(e=>this.support=e,e=>{throw this.loading=null,e}))}static of(e){let{load:t,support:n}=e;if(!t){if(!n)throw new RangeError("Must pass either 'load' or 'support' to LanguageDescription.of");t=()=>Promise.resolve(n)}return new i(e.name,(e.alias||[]).concat(e.name).map(r=>r.toLowerCase()),e.extensions||[],e.filename,t,n)}static matchFilename(e,t){for(let r of e)if(r.filename&&r.filename.test(t))return r;let n=/\.([^.]+)$/.exec(t);if(n){for(let r of e)if(r.extensions.indexOf(n[1])>-1)return r}return null}static matchLanguageName(e,t,n=!0){t=t.toLowerCase();for(let r of e)if(r.alias.some(s=>s==t))return r;if(n)for(let r of e)for(let s of r.alias){let o=t.indexOf(s);if(o>-1&&(s.length>2||!/\w/.test(t[o-1])&&!/\w/.test(t[o+s.length])))return r}return null}},iC=R.define(),Ji=R.define({combine:i=>{if(!i.length)return" ";let e=i[0];if(!e||/\S/.test(e)||Array.from(e).some(t=>t!=e[0]))throw new Error("Invalid indent unit: "+JSON.stringify(i[0]));return e}});function Ro(i){let e=i.facet(Ji);return e.charCodeAt(0)==9?i.tabSize*e.length:e.length}function ss(i,e){let t="",n=i.tabSize,r=i.facet(Ji)[0];if(r==" "){for(;e>=n;)t+=" ",e-=n;r=" "}for(let s=0;s=e?nC(i,t,e):null}var qn=class{constructor(e,t={}){this.state=e,this.options=t,this.unit=Ro(e)}lineAt(e,t=1){let n=this.state.doc.lineAt(e),{simulateBreak:r,simulateDoubleBreak:s}=this.options;return r!=null&&r>=n.from&&r<=n.to?s&&r==e?{text:"",from:e}:(t<0?r-1&&(s+=o-this.countColumn(n,n.search(/\S|$/))),s}countColumn(e,t=e.length){return Ke(e,this.state.tabSize,t)}lineIndent(e,t=1){let{text:n,from:r}=this.lineAt(e,t),s=this.options.overrideIndentation;if(s){let o=s(r);if(o>-1)return o}return this.countColumn(n,n.search(/\S|$/))}get simulatedBreak(){return this.options.simulateBreak||null}},$i=new U;function nC(i,e,t){let n=e.resolveStack(t),r=e.resolveInner(t,-1).resolve(t,0).enterUnfinishedNodesBefore(t);if(r!=n.node){let s=[];for(let o=r;o&&!(o.fromn.node.to||o.from==n.node.from&&o.type==n.node.type);o=o.parent)s.push(o);for(let o=s.length-1;o>=0;o--)n={node:s[o],next:n}}return Qb(n,i,t)}function Qb(i,e,t){for(let n=i;n;n=n.next){let r=sC(n.node);if(r)return r(Cd.create(e,t,n))}return 0}function rC(i){return i.pos==i.options.simulateBreak&&i.options.simulateDoubleBreak}function sC(i){let e=i.type.prop($i);if(e)return e;let t=i.firstChild,n;if(t&&(n=t.type.prop(U.closedBy))){let r=i.lastChild,s=r&&n.indexOf(r.name)>-1;return o=>$b(o,!0,1,void 0,s&&!rC(o)?r.from:void 0)}return i.parent==null?oC:null}function oC(){return 0}var Cd=class i extends qn{constructor(e,t,n){super(e.state,e.options),this.base=e,this.pos=t,this.context=n}get node(){return this.context.node}static create(e,t,n){return new i(e,t,n)}get textAfter(){return this.textAfterPos(this.pos)}get baseIndent(){return this.baseIndentFor(this.node)}baseIndentFor(e){let t=this.state.doc.lineAt(e.from);for(;;){let n=e.resolve(t.from);for(;n.parent&&n.parent.from==n.from;)n=n.parent;if(lC(n,e))break;t=this.state.doc.lineAt(n.from)}return this.lineIndent(t.from)}continue(){return Qb(this.context.next,this.base,this.pos)}};function lC(i,e){for(let t=e;t;t=t.parent)if(i==t)return!0;return!1}function aC(i){let e=i.node,t=e.childAfter(e.from),n=e.lastChild;if(!t)return null;let r=i.options.simulateBreak,s=i.state.doc.lineAt(t.from),o=r==null||r<=s.from?s.to:Math.min(s.to,r);for(let l=t.to;;){let a=e.childAfter(l);if(!a||a==n)return null;if(!a.type.isSkipped){if(a.from>=o)return null;let c=/^ */.exec(s.text.slice(t.to-s.from))[0].length;return{from:t.from,to:t.to+c}}l=a.to}}function vb({closing:i,align:e=!0,units:t=1}){return n=>$b(n,e,t,i)}function $b(i,e,t,n,r){let s=i.textAfter,o=s.match(/^\s*/)[0].length,l=n&&s.slice(o,o+n.length)==n||r==i.pos+o,a=e?aC(i):null;return a?l?i.column(a.from):i.column(a.to):i.baseIndent+(l?0:i.unit*t)}var Pb=i=>i.baseIndent;function os({except:i,units:e=1}={}){return t=>{let n=i&&i.test(t.textAfter);return t.baseIndent+(n?0:e*t.unit)}}var cC=200;function Cb(){return ne.transactionFilter.of(i=>{if(!i.docChanged||!i.isUserEvent("input.type")&&!i.isUserEvent("input.complete"))return i;let e=i.startState.languageDataAt("indentOnInput",i.startState.selection.main.head);if(!e.length)return i;let t=i.newDoc,{head:n}=i.newSelection.main,r=t.lineAt(n);if(n>r.from+cC)return i;let s=t.sliceString(r.from,n);if(!e.some(c=>c.test(s)))return i;let{state:o}=i,l=-1,a=[];for(let{head:c}of o.selection.ranges){let h=o.doc.lineAt(c);if(h.from==l)continue;l=h.from;let f=yc(o,h.from);if(f==null)continue;let u=/^\s*/.exec(h.text)[0],d=ss(o,f);u!=d&&a.push({from:h.from,to:h.from+u.length,insert:d})}return a.length?[i,{changes:a,sequential:!0}]:i})}var Ed=R.define(),fi=new U;function Sc(i){let e=i.firstChild,t=i.lastChild;return e&&e.tot)continue;if(s&&l.from=e&&c.to>t&&(s=c)}}return s}function fC(i){let e=i.lastChild;return e&&e.to==i.to&&e.type.isError}function Oc(i,e,t){for(let n of i.facet(Ed)){let r=n(i,e,t);if(r)return r}return hC(i,e,t)}function Tb(i,e){let t=e.mapPos(i.from,1),n=e.mapPos(i.to,-1);return t>=n?void 0:{from:t,to:n}}var xc=Y.define({map:Tb}),Xo=Y.define({map:Tb});function Ab(i){let e=[];for(let{head:t}of i.state.selection.ranges)e.some(n=>n.from<=t&&n.to>=t)||e.push(i.lineBlockAt(t));return e}var Gn=de.define({create(){return L.none},update(i,e){e.isUserEvent("delete")&&e.changes.iterChangedRanges((n,r)=>i=mb(i,n,r)),i=i.map(e.changes);let t=[];for(let n of e.effects)n.is(xc)&&!uC(i,n.value.from,n.value.to)?t.push(n.value):n.is(Xo)&&(i=i.update({filter:(r,s)=>n.value.from!=r||n.value.to!=s,filterFrom:n.value.from,filterTo:n.value.to}));if(t.length){let{preparePlaceholder:n}=e.state.facet(Zd),r=t.map(s=>(n?L.replace({widget:new Td(n(e.state,s))}):bb).range(s.from,s.to));i=i.update({add:r})}return e.selection&&(i=mb(i,e.selection.main.head)),i},provide:i=>M.decorations.from(i),toJSON(i,e){let t=[];return i.between(0,e.doc.length,(n,r)=>{t.push(n,r)}),t},fromJSON(i){if(!Array.isArray(i)||i.length%2)throw new RangeError("Invalid JSON for fold state");let e=[];for(let t=0;t{re&&(n=!0)}),n?i.update({filterFrom:e,filterTo:t,filter:(r,s)=>r>=t||s<=e}):i}function gc(i,e,t){var n;let r=null;return(n=i.field(Gn,!1))===null||n===void 0||n.between(e,t,(s,o)=>{(!r||r.from>s)&&(r={from:s,to:o})}),r}function uC(i,e,t){let n=!1;return i.between(e,e,(r,s)=>{r==e&&s==t&&(n=!0)}),n}function Mb(i,e){return i.field(Gn,!1)?e:e.concat(Y.appendConfig.of(Eb()))}var dC=i=>{for(let e of Ab(i)){let t=Oc(i.state,e.from,e.to);if(t)return i.dispatch({effects:Mb(i.state,[xc.of(t),Rb(i,t)])}),!0}return!1},pC=i=>{if(!i.state.field(Gn,!1))return!1;let e=[];for(let t of Ab(i)){let n=gc(i.state,t.from,t.to);n&&e.push(Xo.of(n),Rb(i,n,!1))}return e.length&&i.dispatch({effects:e}),e.length>0};function Rb(i,e,t=!0){let n=i.state.doc.lineAt(e.from).number,r=i.state.doc.lineAt(e.to).number;return M.announce.of(`${i.state.phrase(t?"Folded lines":"Unfolded lines")} ${n} ${i.state.phrase("to")} ${r}.`)}var OC=i=>{let{state:e}=i,t=[];for(let n=0;n{let e=i.state.field(Gn,!1);if(!e||!e.size)return!1;let t=[];return e.between(0,i.state.doc.length,(n,r)=>{t.push(Xo.of({from:n,to:r}))}),i.dispatch({effects:t}),!0};var Xb=[{key:"Ctrl-Shift-[",mac:"Cmd-Alt-[",run:dC},{key:"Ctrl-Shift-]",mac:"Cmd-Alt-]",run:pC},{key:"Ctrl-Alt-[",run:OC},{key:"Ctrl-Alt-]",run:gC}],mC={placeholderDOM:null,preparePlaceholder:null,placeholderText:"\u2026"},Zd=R.define({combine(i){return Ye(i,mC)}});function Eb(i){let e=[Gn,yC];return i&&e.push(Zd.of(i)),e}function Zb(i,e){let{state:t}=i,n=t.facet(Zd),r=o=>{let l=i.lineBlockAt(i.posAtDOM(o.target)),a=gc(i.state,l.from,l.to);a&&i.dispatch({effects:Xo.of(a)}),o.preventDefault()};if(n.placeholderDOM)return n.placeholderDOM(i,r,e);let s=document.createElement("span");return s.textContent=n.placeholderText,s.setAttribute("aria-label",t.phrase("folded code")),s.title=t.phrase("unfold"),s.className="cm-foldPlaceholder",s.onclick=r,s}var bb=L.replace({widget:new class extends De{toDOM(i){return Zb(i,null)}}}),Td=class extends De{constructor(e){super(),this.value=e}eq(e){return this.value==e.value}toDOM(e){return Zb(e,this.value)}},bC={openText:"\u2304",closedText:"\u203A",markerDOM:null,domEventHandlers:{},foldingChanged:()=>!1},Po=class extends yt{constructor(e,t){super(),this.config=e,this.open=t}eq(e){return this.config==e.config&&this.open==e.open}toDOM(e){if(this.config.markerDOM)return this.config.markerDOM(this.open);let t=document.createElement("span");return t.textContent=this.open?this.config.openText:this.config.closedText,t.title=e.state.phrase(this.open?"Fold line":"Unfold line"),t}};function _b(i={}){let e={...bC,...i},t=new Po(e,!0),n=new Po(e,!1),r=se.fromClass(class{constructor(o){this.from=o.viewport.from,this.markers=this.buildMarkers(o)}update(o){(o.docChanged||o.viewportChanged||o.startState.facet(Ki)!=o.state.facet(Ki)||o.startState.field(Gn,!1)!=o.state.field(Gn,!1)||ee(o.startState)!=ee(o.state)||e.foldingChanged(o))&&(this.markers=this.buildMarkers(o.view))}buildMarkers(o){let l=new lt;for(let a of o.viewportLineBlocks){let c=gc(o.state,a.from,a.to)?n:Oc(o.state,a.from,a.to)?t:null;c&&l.add(a.from,a.from,c)}return l.finish()}}),{domEventHandlers:s}=e;return[r,id({class:"cm-foldGutter",markers(o){var l;return((l=o.plugin(r))===null||l===void 0?void 0:l.markers)||G.empty},initialSpacer(){return new Po(e,!1)},domEventHandlers:{...s,click:(o,l,a)=>{if(s.click&&s.click(o,l,a))return!0;let c=gc(o.state,l.from,l.to);if(c)return o.dispatch({effects:Xo.of(c)}),!0;let h=Oc(o.state,l.from,l.to);return h?(o.dispatch({effects:xc.of(h)}),!0):!1}}}),Eb()]}var yC=M.baseTheme({".cm-foldPlaceholder":{backgroundColor:"#eee",border:"1px solid #ddd",color:"#888",borderRadius:".2em",margin:"0 1px",padding:"0 1px",cursor:"pointer"},".cm-foldGutter span":{padding:"0 1px",cursor:"pointer"}}),mc=class i{constructor(e,t){this.specs=e;let n;function r(l){let a=Tt.newName();return(n||(n=Object.create(null)))["."+a]=l,a}let s=typeof t.all=="string"?t.all:t.all?r(t.all):void 0,o=t.scope;this.scope=o instanceof tt?l=>l.prop(Fi)==o.data:o?l=>l==o:void 0,this.style=wd(e.map(l=>({tag:l.tag,class:l.class||r(Object.assign({},l,{tag:null}))})),{all:s}).style,this.module=n?new Tt(n):null,this.themeType=t.themeType}static define(e,t){return new i(e,t||{})}},Ad=R.define(),Lb=R.define({combine(i){return i.length?[i[0]]:null}});function vd(i){let e=i.facet(Ad);return e.length?e:i.facet(Lb)}function Db(i,e){let t=[SC],n;return i instanceof mc&&(i.module&&t.push(M.styleModule.of(i.module)),n=i.themeType),e?.fallback?t.push(Lb.of(i)):n?t.push(Ad.computeN([M.darkTheme],r=>r.facet(M.darkTheme)==(n=="dark")?[i]:[])):t.push(Ad.of(i)),t}var Md=class{constructor(e){this.markCache=Object.create(null),this.tree=ee(e.state),this.decorations=this.buildDeco(e,vd(e.state)),this.decoratedTo=e.viewport.to}update(e){let t=ee(e.state),n=vd(e.state),r=n!=vd(e.startState),{viewport:s}=e.view,o=e.changes.mapPos(this.decoratedTo,1);t.length=s.to?(this.decorations=this.decorations.map(e.changes),this.decoratedTo=o):(t!=this.tree||e.viewportChanged||r)&&(this.tree=t,this.decorations=this.buildDeco(e.view,n),this.decoratedTo=s.to)}buildDeco(e,t){if(!t||!this.tree.length)return L.none;let n=new lt;for(let{from:r,to:s}of e.visibleRanges)pb(this.tree,t,(o,l,a)=>{n.add(o,l,this.markCache[a]||(this.markCache[a]=L.mark({class:a})))},r,s);return n.finish()}},SC=at.high(se.fromClass(Md,{decorations:i=>i.decorations})),jb=mc.define([{tag:m.meta,color:"#404740"},{tag:m.link,textDecoration:"underline"},{tag:m.heading,textDecoration:"underline",fontWeight:"bold"},{tag:m.emphasis,fontStyle:"italic"},{tag:m.strong,fontWeight:"bold"},{tag:m.strikethrough,textDecoration:"line-through"},{tag:m.keyword,color:"#708"},{tag:[m.atom,m.bool,m.url,m.contentSeparator,m.labelName],color:"#219"},{tag:[m.literal,m.inserted],color:"#164"},{tag:[m.string,m.deleted],color:"#a11"},{tag:[m.regexp,m.escape,m.special(m.string)],color:"#e40"},{tag:m.definition(m.variableName),color:"#00f"},{tag:m.local(m.variableName),color:"#30a"},{tag:[m.typeName,m.namespace],color:"#085"},{tag:m.className,color:"#167"},{tag:[m.special(m.variableName),m.macroName],color:"#256"},{tag:m.definition(m.propertyName),color:"#00c"},{tag:m.comment,color:"#940"},{tag:m.invalid,color:"#f00"}]),xC=M.baseTheme({"&.cm-focused .cm-matchingBracket":{backgroundColor:"#328c8252"},"&.cm-focused .cm-nonmatchingBracket":{backgroundColor:"#bb555544"}}),Ib=1e4,Ub="()[]{}",Bb=R.define({combine(i){return Ye(i,{afterCursor:!0,brackets:Ub,maxScanDistance:Ib,renderMatch:QC})}}),wC=L.mark({class:"cm-matchingBracket"}),kC=L.mark({class:"cm-nonmatchingBracket"});function QC(i){let e=[],t=i.matched?wC:kC;return e.push(t.range(i.start.from,i.start.to)),i.end&&e.push(t.range(i.end.from,i.end.to)),e}function yb(i){let e=[],t=i.facet(Bb);for(let n of i.selection.ranges){if(!n.empty)continue;let r=Vt(i,n.head,-1,t)||n.head>0&&Vt(i,n.head-1,1,t)||t.afterCursor&&(Vt(i,n.head,1,t)||n.headi.decorations}),$C=[vC,xC];function Vb(i={}){return[Bb.of(i),$C]}var _d=new U;function Rd(i,e,t){let n=i.prop(e<0?U.openedBy:U.closedBy);if(n)return n;if(i.name.length==1){let r=t.indexOf(i.name);if(r>-1&&r%2==(e<0?1:0))return[t[r+e]]}return null}function Xd(i){let e=i.type.prop(_d);return e?e(i.node):i}function Vt(i,e,t,n={}){let r=n.maxScanDistance||Ib,s=n.brackets||Ub,o=ee(i),l=o.resolveInner(e,t);for(let a=l;a;a=a.parent){let c=Rd(a.type,t,s);if(c&&a.from0?e>=h.from&&eh.from&&e<=h.to))return PC(i,e,t,a,h,c,s)}}return CC(i,e,t,o,l.type,r,s)}function PC(i,e,t,n,r,s,o){let l=n.parent,a={from:r.from,to:r.to},c=0,h=l?.cursor();if(h&&(t<0?h.childBefore(n.from):h.childAfter(n.to)))do if(t<0?h.to<=n.from:h.from>=n.to){if(c==0&&s.indexOf(h.type.name)>-1&&h.from0)return null;let c={from:t<0?e-1:e,to:t>0?e+1:e},h=i.doc.iterRange(e,t>0?i.doc.length:0),f=0;for(let u=0;!h.next().done&&u<=s;){let d=h.value;t<0&&(u+=d.length);let p=e+u*t;for(let O=t>0?0:d.length-1,g=t>0?d.length:-1;O!=g;O+=t){let b=o.indexOf(d[O]);if(!(b<0||n.resolveInner(p+O,1).type!=r))if(b%2==0==t>0)f++;else{if(f==1)return{start:c,end:{from:p+O,to:p+O+1},matched:b>>1==a>>1};f--}}t>0&&(u+=d.length)}return h.done?{start:c,matched:!1}:null}var TC=Object.create(null),Sb=[Oe.none];var xb=[],wb=Object.create(null),AC=Object.create(null);for(let[i,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])AC[i]=MC(TC,e);function $d(i,e){xb.indexOf(i)>-1||(xb.push(i),console.warn(e))}function MC(i,e){let t=[];for(let l of e.split(" ")){let a=[];for(let c of l.split(".")){let h=i[c]||m[c];h?typeof h=="function"?a.length?a=a.map(h):$d(c,`Modifier ${c} used at start of tag`):a.length?$d(c,`Tag ${c} used as modifier`):a=Array.isArray(h)?h:[h]:$d(c,`Unknown highlighting tag ${c}`)}for(let c of a)t.push(c)}if(!t.length)return 0;let n=e.replace(/ /g,"_"),r=n+" "+t.map(l=>l.id),s=wb[r];if(s)return s.id;let o=wb[r]=Oe.define({id:Sb.length,name:n,props:[Bt({[n]:t})]});return Sb.push(o),o.id}var YX={rtl:L.mark({class:"cm-iso",inclusive:!0,attributes:{dir:"rtl"},bidiIsolate:re.RTL}),ltr:L.mark({class:"cm-iso",inclusive:!0,attributes:{dir:"ltr"},bidiIsolate:re.LTR}),auto:L.mark({class:"cm-iso",inclusive:!0,attributes:{dir:"auto"},bidiIsolate:null})};var RC=i=>{let{state:e}=i,t=e.doc.lineAt(e.selection.main.from),n=Vd(i.state,t.from);return n.line?XC(i):n.block?ZC(i):!1};function Bd(i,e){return({state:t,dispatch:n})=>{if(t.readOnly)return!1;let r=i(e,t);return r?(n(t.update(r)),!0):!1}}var XC=Bd(DC,0);var EC=Bd(Kb,0);var ZC=Bd((i,e)=>Kb(i,e,LC(e)),0);function Vd(i,e){let t=i.languageDataAt("commentTokens",e,1);return t.length?t[0]:{}}var Eo=50;function _C(i,{open:e,close:t},n,r){let s=i.sliceDoc(n-Eo,n),o=i.sliceDoc(r,r+Eo),l=/\s*$/.exec(s)[0].length,a=/^\s*/.exec(o)[0].length,c=s.length-l;if(s.slice(c-e.length,c)==e&&o.slice(a,a+t.length)==t)return{open:{pos:n-l,margin:l&&1},close:{pos:r+a,margin:a&&1}};let h,f;r-n<=2*Eo?h=f=i.sliceDoc(n,r):(h=i.sliceDoc(n,n+Eo),f=i.sliceDoc(r-Eo,r));let u=/^\s*/.exec(h)[0].length,d=/\s*$/.exec(f)[0].length,p=f.length-d-t.length;return h.slice(u,u+e.length)==e&&f.slice(p,p+t.length)==t?{open:{pos:n+u+e.length,margin:/\s/.test(h.charAt(u+e.length))?1:0},close:{pos:r-d-t.length,margin:/\s/.test(f.charAt(p-1))?1:0}}:null}function LC(i){let e=[];for(let t of i.selection.ranges){let n=i.doc.lineAt(t.from),r=t.to<=n.to?n:i.doc.lineAt(t.to);r.from>n.from&&r.from==t.to&&(r=t.to==n.to+1?n:i.doc.lineAt(t.to-1));let s=e.length-1;s>=0&&e[s].to>n.from?e[s].to=r.to:e.push({from:n.from+/^\s*/.exec(n.text)[0].length,to:r.to})}return e}function Kb(i,e,t=e.selection.ranges){let n=t.map(s=>Vd(e,s.from).block);if(!n.every(s=>s))return null;let r=t.map((s,o)=>_C(e,n[o],s.from,s.to));if(i!=2&&!r.every(s=>s))return{changes:e.changes(t.map((s,o)=>r[o]?[]:[{from:s.from,insert:n[o].open+" "},{from:s.to,insert:" "+n[o].close}]))};if(i!=1&&r.some(s=>s)){let s=[];for(let o=0,l;or&&(s==o||o>f.from)){r=f.from;let u=/^\s*/.exec(f.text)[0].length,d=u==f.length,p=f.text.slice(u,u+c.length)==c?u:-1;us.comment<0&&(!s.empty||s.single))){let s=[];for(let{line:l,token:a,indent:c,empty:h,single:f}of n)(f||!h)&&s.push({from:l.from+c,insert:a+" "});let o=e.changes(s);return{changes:o,selection:e.selection.map(o,1)}}else if(i!=1&&n.some(s=>s.comment>=0)){let s=[];for(let{line:o,comment:l,token:a}of n)if(l>=0){let c=o.from+l,h=c+a.length;o.text[h-o.from]==" "&&h++,s.push({from:c,to:h})}return{changes:s}}return null}var Dd=we.define(),jC=we.define(),IC=R.define(),Jb=R.define({combine(i){return Ye(i,{minDepth:100,newGroupDelay:500,joinToEvent:(e,t)=>t},{minDepth:Math.max,newGroupDelay:Math.min,joinToEvent:(e,t)=>(n,r)=>e(n,r)||t(n,r)})}}),ey=de.define({create(){return Fn.empty},update(i,e){let t=e.state.facet(Jb),n=e.annotation(Dd);if(n){let a=zt.fromTransaction(e,n.selection),c=n.side,h=c==0?i.undone:i.done;return a?h=kc(h,h.length,t.minDepth,a):h=ry(h,e.startState.selection),new Fn(c==0?n.rest:h,c==0?h:n.rest)}let r=e.annotation(jC);if((r=="full"||r=="before")&&(i=i.isolate()),e.annotation(xe.addToHistory)===!1)return e.changes.empty?i:i.addMapping(e.changes.desc);let s=zt.fromTransaction(e),o=e.annotation(xe.time),l=e.annotation(xe.userEvent);return s?i=i.addChanges(s,o,l,t,e):e.selection&&(i=i.addSelection(e.startState.selection,o,l,t.newGroupDelay)),(r=="full"||r=="after")&&(i=i.isolate()),i},toJSON(i){return{done:i.done.map(e=>e.toJSON()),undone:i.undone.map(e=>e.toJSON())}},fromJSON(i){return new Fn(i.done.map(zt.fromJSON),i.undone.map(zt.fromJSON))}});function ty(i={}){return[ey,Jb.of(i),M.domEventHandlers({beforeinput(e,t){let n=e.inputType=="historyUndo"?iy:e.inputType=="historyRedo"?jd:null;return n?(e.preventDefault(),n(t)):!1}})]}function Qc(i,e){return function({state:t,dispatch:n}){if(!e&&t.readOnly)return!1;let r=t.field(ey,!1);if(!r)return!1;let s=r.pop(i,t,e);return s?(n(s),!0):!1}}var iy=Qc(0,!1),jd=Qc(1,!1),UC=Qc(0,!0),BC=Qc(1,!0);var zt=class i{constructor(e,t,n,r,s){this.changes=e,this.effects=t,this.mapped=n,this.startSelection=r,this.selectionsAfter=s}setSelAfter(e){return new i(this.changes,this.effects,this.mapped,this.startSelection,e)}toJSON(){var e,t,n;return{changes:(e=this.changes)===null||e===void 0?void 0:e.toJSON(),mapped:(t=this.mapped)===null||t===void 0?void 0:t.toJSON(),startSelection:(n=this.startSelection)===null||n===void 0?void 0:n.toJSON(),selectionsAfter:this.selectionsAfter.map(r=>r.toJSON())}}static fromJSON(e){return new i(e.changes&&He.fromJSON(e.changes),[],e.mapped&&yi.fromJSON(e.mapped),e.startSelection&&v.fromJSON(e.startSelection),e.selectionsAfter.map(v.fromJSON))}static fromTransaction(e,t){let n=Xt;for(let r of e.startState.facet(IC)){let s=r(e);s.length&&(n=n.concat(s))}return!n.length&&e.changes.empty?null:new i(e.changes.invert(e.startState.doc),n,void 0,t||e.startState.selection,Xt)}static selection(e){return new i(void 0,Xt,void 0,void 0,e)}};function kc(i,e,t,n){let r=e+1>t+20?e-t-1:0,s=i.slice(r,e);return s.push(n),s}function VC(i,e){let t=[],n=!1;return i.iterChangedRanges((r,s)=>t.push(r,s)),e.iterChangedRanges((r,s,o,l)=>{for(let a=0;a=c&&o<=h&&(n=!0)}}),n}function YC(i,e){return i.ranges.length==e.ranges.length&&i.ranges.filter((t,n)=>t.empty!=e.ranges[n].empty).length===0}function ny(i,e){return i.length?e.length?i.concat(e):i:e}var Xt=[],zC=200;function ry(i,e){if(i.length){let t=i[i.length-1],n=t.selectionsAfter.slice(Math.max(0,t.selectionsAfter.length-zC));return n.length&&n[n.length-1].eq(e)?i:(n.push(e),kc(i,i.length-1,1e9,t.setSelAfter(n)))}else return[zt.selection([e])]}function WC(i){let e=i[i.length-1],t=i.slice();return t[i.length-1]=e.setSelAfter(e.selectionsAfter.slice(0,e.selectionsAfter.length-1)),t}function Ld(i,e){if(!i.length)return i;let t=i.length,n=Xt;for(;t;){let r=NC(i[t-1],e,n);if(r.changes&&!r.changes.empty||r.effects.length){let s=i.slice(0,t);return s[t-1]=r,s}else e=r.mapped,t--,n=r.selectionsAfter}return n.length?[zt.selection(n)]:Xt}function NC(i,e,t){let n=ny(i.selectionsAfter.length?i.selectionsAfter.map(l=>l.map(e)):Xt,t);if(!i.changes)return zt.selection(n);let r=i.changes.map(e),s=e.mapDesc(i.changes,!0),o=i.mapped?i.mapped.composeDesc(s):s;return new zt(r,Y.mapEffects(i.effects,e),o,i.startSelection.map(s),n)}var qC=/^(input\.type|delete)($|\.)/,Fn=class i{constructor(e,t,n=0,r=void 0){this.done=e,this.undone=t,this.prevTime=n,this.prevUserEvent=r}isolate(){return this.prevTime?new i(this.done,this.undone):this}addChanges(e,t,n,r,s){let o=this.done,l=o[o.length-1];return l&&l.changes&&!l.changes.empty&&e.changes&&(!n||qC.test(n))&&(!l.selectionsAfter.length&&t-this.prevTime0&&t-this.prevTimet.empty?i.moveByChar(t,e):vc(t,e))}function ze(i){return i.textDirectionAt(i.state.selection.main.head)==re.LTR}var ly=i=>oy(i,!ze(i)),ay=i=>oy(i,ze(i));function cy(i,e){return Nt(i,t=>t.empty?i.moveByGroup(t,e):vc(t,e))}var GC=i=>cy(i,!ze(i)),FC=i=>cy(i,ze(i));var eE=typeof Intl<"u"&&Intl.Segmenter?new Intl.Segmenter(void 0,{granularity:"word"}):null;function HC(i,e,t){if(e.type.prop(t))return!0;let n=e.to-e.from;return n&&(n>2||/[^\s,.;:]/.test(i.sliceDoc(e.from,e.to)))||e.firstChild}function $c(i,e,t){let n=ee(i).resolveInner(e.head),r=t?U.closedBy:U.openedBy;for(let a=e.head;;){let c=t?n.childAfter(a):n.childBefore(a);if(!c)break;HC(i,c,r)?n=c:a=t?c.to:c.from}let s=n.type.prop(r),o,l;return s&&(o=t?Vt(i,n.from,1):Vt(i,n.to,-1))&&o.matched?l=t?o.end.to:o.end.from:l=t?n.to:n.from,v.cursor(l,t?-1:1)}var KC=i=>Nt(i,e=>$c(i.state,e,!ze(i))),JC=i=>Nt(i,e=>$c(i.state,e,ze(i)));function hy(i,e){return Nt(i,t=>{if(!t.empty)return vc(t,e);let n=i.moveVertically(t,e);return n.head!=t.head?n:i.moveToLineBoundary(t,e)})}var fy=i=>hy(i,!1),uy=i=>hy(i,!0);function dy(i){let e=i.scrollDOM.clientHeighto.empty?i.moveVertically(o,e,t.height):vc(o,e));if(r.eq(n.selection))return!1;let s;if(t.selfScroll){let o=i.coordsAtPos(n.selection.main.head),l=i.scrollDOM.getBoundingClientRect(),a=l.top+t.marginTop,c=l.bottom-t.marginBottom;o&&o.top>a&&o.bottompy(i,!1),Id=i=>py(i,!0);function en(i,e,t){let n=i.lineBlockAt(e.head),r=i.moveToLineBoundary(e,t);if(r.head==e.head&&r.head!=(t?n.to:n.from)&&(r=i.moveToLineBoundary(e,t,!1)),!t&&r.head==n.from&&n.length){let s=/^\s*/.exec(i.state.sliceDoc(n.from,Math.min(n.from+100,n.to)))[0].length;s&&e.head!=n.from+s&&(r=v.cursor(n.from+s))}return r}var eT=i=>Nt(i,e=>en(i,e,!0)),tT=i=>Nt(i,e=>en(i,e,!1)),iT=i=>Nt(i,e=>en(i,e,!ze(i))),nT=i=>Nt(i,e=>en(i,e,ze(i))),rT=i=>Nt(i,e=>i.moveToLineBoundary(e,!1,!1)),sT=i=>Nt(i,e=>i.moveToLineBoundary(e,!0,!1));function oT(i,e,t){let n=!1,r=ls(i.selection,s=>{let o=Vt(i,s.head,-1)||Vt(i,s.head,1)||s.head>0&&Vt(i,s.head-1,1)||s.headoT(i,e,!1);function Et(i,e,t){let n=ls(i.state.selection,r=>{r.undirectional&&r.head>=r.anchor!=e&&(r=v.range(r.head,r.anchor));let s=t(r);return v.range(r.anchor,s.head,s.goalColumn,s.bidiLevel||void 0,s.assoc)});return n.eq(i.state.selection)?!1:(i.dispatch(Wt(i.state,n)),!0)}function Oy(i,e){return Et(i,e,t=>i.moveByChar(t,e))}var gy=i=>Oy(i,!ze(i)),my=i=>Oy(i,ze(i));function by(i,e){return Et(i,e,t=>i.moveByGroup(t,e))}var aT=i=>by(i,!ze(i)),cT=i=>by(i,ze(i));var hT=i=>{let e=!ze(i);return Et(i,e,t=>$c(i.state,t,e))},fT=i=>{let e=ze(i);return Et(i,e,t=>$c(i.state,t,e))};function yy(i,e){return Et(i,e,t=>i.moveVertically(t,e))}var Sy=i=>yy(i,!1),xy=i=>yy(i,!0);function wy(i,e){return Et(i,e,t=>i.moveVertically(t,e,dy(i).height))}var zb=i=>wy(i,!1),Wb=i=>wy(i,!0),uT=i=>Et(i,!0,e=>en(i,e,!0)),dT=i=>Et(i,!1,e=>en(i,e,!1)),pT=i=>{let e=!ze(i);return Et(i,e,t=>en(i,t,e))},OT=i=>{let e=ze(i);return Et(i,e,t=>en(i,t,e))},gT=i=>Et(i,!1,e=>v.cursor(i.lineBlockAt(e.head).from)),mT=i=>Et(i,!0,e=>v.cursor(i.lineBlockAt(e.head).to)),Nb=({state:i,dispatch:e})=>(e(Wt(i,{anchor:0})),!0),qb=({state:i,dispatch:e})=>(e(Wt(i,{anchor:i.doc.length})),!0),Gb=({state:i,dispatch:e})=>(e(Wt(i,{anchor:i.selection.main.anchor,head:0})),!0),Fb=({state:i,dispatch:e})=>(e(Wt(i,{anchor:i.selection.main.anchor,head:i.doc.length})),!0),bT=({state:i,dispatch:e})=>(e(i.update({selection:{anchor:0,head:i.doc.length},userEvent:"select"})),!0),yT=({state:i,dispatch:e})=>{let t=Pc(i).map(({from:n,to:r})=>v.undirectionalRange(n,Math.min(r+1,i.doc.length)));return e(i.update({selection:v.create(t),userEvent:"select"})),!0},ST=({state:i,dispatch:e})=>{let t=ls(i.selection,n=>{let r=ee(i),s=r.resolveStack(n.from,1);if(n.empty){let o=r.resolveStack(n.from,-1);o.node.from>=s.node.from&&o.node.to<=s.node.to&&(s=o)}for(let o=s;o;o=o.next){let{node:l}=o;if((l.from=n.to||l.to>n.to&&l.from<=n.from)&&o.next)return v.undirectionalRange(l.from,l.to)}return n});return t.eq(i.selection)?!1:(e(Wt(i,t)),!0)};function ky(i,e){let{state:t}=i,n=t.selection,r=t.selection.ranges.slice();for(let s of t.selection.ranges){let o=t.doc.lineAt(s.head);if(e?o.to0)for(let l=s;;){let a=i.moveVertically(l,e);if(a.heado.to){r.some(c=>c.head==a.head)||r.push(a);break}else{if(a.head==l.head)break;l=a}}}return r.length==n.ranges.length?!1:(i.dispatch(Wt(t,v.create(r,r.length-1))),!0)}var xT=i=>ky(i,!1),wT=i=>ky(i,!0),kT=({state:i,dispatch:e})=>{let t=i.selection,n=null;return t.ranges.length>1?n=v.create([t.main]):t.main.empty||(n=v.create([v.cursor(t.main.head)])),n?(e(Wt(i,n)),!0):!1};function Zo(i,e){if(i.state.readOnly)return!1;let t="delete.selection",{state:n}=i,r=n.changeByRange(s=>{let{from:o,to:l}=s;if(o==l){let a=e(s);ao&&(t="delete.forward",a=wc(i,a,!0)),o=Math.min(o,a),l=Math.max(l,a)}else o=wc(i,o,!1),l=wc(i,l,!0);return o==l?{range:s}:{changes:{from:o,to:l},range:v.cursor(o,or(i)))n.between(e,e,(r,s)=>{re&&(e=t?s:r)});return e}var Qy=(i,e,t)=>Zo(i,n=>{let r=n.from,{state:s}=i,o=s.doc.lineAt(r),l,a;if(t&&!e&&r>o.from&&rQy(i,!1,!0);var vy=i=>Qy(i,!0,!1),$y=(i,e)=>Zo(i,t=>{let n=t.head,{state:r}=i,s=r.doc.lineAt(n),o=r.charCategorizer(n);for(let l=null;;){if(n==(e?s.to:s.from)){n==t.head&&s.number!=(e?r.doc.lines:1)&&(n+=e?1:-1);break}let a=be(s.text,n-s.from,e)+s.from,c=s.text.slice(Math.min(n,a)-s.from,Math.max(n,a)-s.from),h=o(c);if(l!=null&&h!=l)break;(c!=" "||n!=t.head)&&(l=h),n=a}return n}),Py=i=>$y(i,!1),QT=i=>$y(i,!0);var vT=i=>Zo(i,e=>{let t=i.lineBlockAt(e.head).to;return e.headZo(i,e=>{let t=i.moveToLineBoundary(e,!1).head;return e.head>t?t:Math.max(0,e.head-1)}),PT=i=>Zo(i,e=>{let t=i.moveToLineBoundary(e,!0).head;return e.head{if(i.readOnly)return!1;let t=i.changeByRange(n=>({changes:{from:n.from,to:n.to,insert:q.of(["",""])},range:v.cursor(n.from)}));return e(i.update(t,{scrollIntoView:!0,userEvent:"input"})),!0},TT=({state:i,dispatch:e})=>{if(i.readOnly)return!1;let t=i.changeByRange(n=>{if(!n.empty||n.from==0||n.from==i.doc.length)return{range:n};let r=n.from,s=i.doc.lineAt(r),o=r==s.from?r-1:be(s.text,r-s.from,!1)+s.from,l=r==s.to?r+1:be(s.text,r-s.from,!0)+s.from;return{changes:{from:o,to:l,insert:i.doc.slice(r,l).append(i.doc.slice(o,r))},range:v.cursor(l)}});return t.changes.empty?!1:(e(i.update(t,{scrollIntoView:!0,userEvent:"move.character"})),!0)};function Pc(i){let e=[],t=-1;for(let n of i.selection.ranges){let r=i.doc.lineAt(n.from),s=i.doc.lineAt(n.to);if(!n.empty&&n.to==s.from&&(s=i.doc.lineAt(n.to-1)),t>=r.number){let o=e[e.length-1];o.to=s.to,o.ranges.push(n)}else e.push({from:r.from,to:s.to,ranges:[n]});t=s.number+1}return e}function Cy(i,e,t){if(i.readOnly)return!1;let n=[],r=[];for(let s of Pc(i)){if(t?s.to==i.doc.length:s.from==0)continue;let o=i.doc.lineAt(t?s.to+1:s.from-1),l=o.length+1;if(t){n.push({from:s.to,to:o.to},{from:s.from,insert:o.text+i.lineBreak});for(let a of s.ranges)r.push(v.range(Math.min(i.doc.length,a.anchor+l),Math.min(i.doc.length,a.head+l)))}else{n.push({from:o.from,to:s.from},{from:s.to,insert:i.lineBreak+o.text});for(let a of s.ranges)r.push(v.range(a.anchor-l,a.head-l))}}return n.length?(e(i.update({changes:n,scrollIntoView:!0,selection:v.create(r,i.selection.mainIndex),userEvent:"move.line"})),!0):!1}var AT=({state:i,dispatch:e})=>Cy(i,e,!1),MT=({state:i,dispatch:e})=>Cy(i,e,!0);function Ty(i,e,t){if(i.readOnly)return!1;let n=[];for(let s of Pc(i))t?n.push({from:s.from,insert:i.doc.slice(s.from,s.to)+i.lineBreak}):n.push({from:s.to,insert:i.lineBreak+i.doc.slice(s.from,s.to)});let r=i.changes(n);return e(i.update({changes:r,selection:i.selection.map(r,t?1:-1),scrollIntoView:!0,userEvent:"input.copyline"})),!0}var RT=({state:i,dispatch:e})=>Ty(i,e,!1),XT=({state:i,dispatch:e})=>Ty(i,e,!0),ET=i=>{if(i.state.readOnly)return!1;let{state:e}=i,t=e.changes(Pc(e).map(({from:r,to:s})=>(r>0?r--:s{let s;if(i.lineWrapping){let o=i.lineBlockAt(r.head),l=i.coordsAtPos(r.head,r.assoc||1);l&&(s=o.bottom+i.documentTop-l.bottom+i.defaultLineHeight/2)}return i.moveVertically(r,!0,s)}).map(t);return i.dispatch({changes:t,selection:n,scrollIntoView:!0,userEvent:"delete.line"}),!0};function ZT(i,e){if(/\(\)|\[\]|\{\}/.test(i.sliceDoc(e-1,e+1)))return{from:e,to:e};let t=ee(i).resolveInner(e),n=t.childBefore(e),r=t.childAfter(e),s;return n&&r&&n.to<=e&&r.from>=e&&(s=n.type.prop(U.closedBy))&&s.indexOf(r.name)>-1&&i.doc.lineAt(n.to).from==i.doc.lineAt(r.from).from&&!/\S/.test(i.sliceDoc(n.to,r.from))?{from:n.to,to:r.from}:null}var Hb=Ay(!1),_T=Ay(!0);function Ay(i){return({state:e,dispatch:t})=>{if(e.readOnly)return!1;let n=e.changeByRange(r=>{let{from:s,to:o}=r,l=e.doc.lineAt(s),a=!i&&s==o&&ZT(e,s);i&&(s=o=(o<=l.to?l:e.doc.lineAt(o)).to);let c=new qn(e,{simulateBreak:s,simulateDoubleBreak:!!a}),h=yc(c,s);for(h==null&&(h=Ke(/^\s*/.exec(e.doc.lineAt(s).text)[0],e.tabSize));ol.from&&s{let r=[];for(let o=n.from;o<=n.to;){let l=i.doc.lineAt(o);l.number>t&&(n.empty||n.to>l.from)&&(e(l,r,n),t=l.number),o=l.to+1}let s=i.changes(r);return{changes:r,range:v.range(s.mapPos(n.anchor,1),s.mapPos(n.head,1))}})}var LT=({state:i,dispatch:e})=>{if(i.readOnly)return!1;let t=Object.create(null),n=new qn(i,{overrideIndentation:s=>{let o=t[s];return o??-1}}),r=Yd(i,(s,o,l)=>{let a=yc(n,s.from);if(a==null)return;/\S/.test(s.text)||(a=0);let c=/^\s*/.exec(s.text)[0],h=ss(i,a);(c!=h||l.fromi.readOnly?!1:(e(i.update(Yd(i,(t,n)=>{n.push({from:t.from,insert:i.facet(Ji)})}),{userEvent:"input.indent"})),!0),jT=({state:i,dispatch:e})=>i.readOnly?!1:(e(i.update(Yd(i,(t,n)=>{let r=/^\s*/.exec(t.text)[0];if(!r)return;let s=Ke(r,i.tabSize),o=0,l=ss(i,Math.max(0,s-Ro(i)));for(;o(i.setTabFocusMode(),!0);var UT=[{key:"Ctrl-b",run:ly,shift:gy,preventDefault:!0},{key:"Ctrl-f",run:ay,shift:my},{key:"Ctrl-p",run:fy,shift:Sy},{key:"Ctrl-n",run:uy,shift:xy},{key:"Ctrl-a",run:rT,shift:gT},{key:"Ctrl-e",run:sT,shift:mT},{key:"Ctrl-d",run:vy},{key:"Ctrl-h",run:Ud},{key:"Ctrl-k",run:vT},{key:"Ctrl-Alt-h",run:Py},{key:"Ctrl-o",run:CT},{key:"Ctrl-t",run:TT},{key:"Ctrl-v",run:Id}],BT=[{key:"ArrowLeft",run:ly,shift:gy,preventDefault:!0},{key:"Mod-ArrowLeft",mac:"Alt-ArrowLeft",run:GC,shift:aT,preventDefault:!0},{mac:"Cmd-ArrowLeft",run:iT,shift:pT,preventDefault:!0},{key:"ArrowRight",run:ay,shift:my,preventDefault:!0},{key:"Mod-ArrowRight",mac:"Alt-ArrowRight",run:FC,shift:cT,preventDefault:!0},{mac:"Cmd-ArrowRight",run:nT,shift:OT,preventDefault:!0},{key:"ArrowUp",run:fy,shift:Sy,preventDefault:!0},{mac:"Cmd-ArrowUp",run:Nb,shift:Gb},{mac:"Ctrl-ArrowUp",run:Yb,shift:zb},{key:"ArrowDown",run:uy,shift:xy,preventDefault:!0},{mac:"Cmd-ArrowDown",run:qb,shift:Fb},{mac:"Ctrl-ArrowDown",run:Id,shift:Wb},{key:"PageUp",run:Yb,shift:zb},{key:"PageDown",run:Id,shift:Wb},{key:"Home",run:tT,shift:dT,preventDefault:!0},{key:"Mod-Home",run:Nb,shift:Gb},{key:"End",run:eT,shift:uT,preventDefault:!0},{key:"Mod-End",run:qb,shift:Fb},{key:"Enter",run:Hb,shift:Hb},{key:"Mod-a",run:bT},{key:"Backspace",run:Ud,shift:Ud,preventDefault:!0},{key:"Delete",run:vy,preventDefault:!0},{key:"Mod-Backspace",mac:"Alt-Backspace",run:Py,preventDefault:!0},{key:"Mod-Delete",mac:"Alt-Delete",run:QT,preventDefault:!0},{mac:"Mod-Backspace",run:$T,preventDefault:!0},{mac:"Mod-Delete",run:PT,preventDefault:!0}].concat(UT.map(i=>({mac:i.key,run:i.run,shift:i.shift}))),My=[{key:"Alt-ArrowLeft",mac:"Ctrl-ArrowLeft",run:KC,shift:hT},{key:"Alt-ArrowRight",mac:"Ctrl-ArrowRight",run:JC,shift:fT},{key:"Alt-ArrowUp",run:AT},{key:"Shift-Alt-ArrowUp",run:RT},{key:"Alt-ArrowDown",run:MT},{key:"Shift-Alt-ArrowDown",run:XT},{key:"Mod-Alt-ArrowUp",run:xT},{key:"Mod-Alt-ArrowDown",run:wT},{key:"Escape",run:kT},{key:"Mod-Enter",run:_T},{key:"Alt-l",mac:"Ctrl-l",run:yT},{key:"Mod-i",run:ST,preventDefault:!0},{key:"Mod-[",run:jT},{key:"Mod-]",run:DT},{key:"Mod-Alt-\\",run:LT},{key:"Shift-Mod-k",run:ET},{key:"Shift-Mod-\\",run:lT},{key:"Mod-/",run:RC},{key:"Alt-A",mac:"Ctrl-A",run:EC},{key:"Ctrl-m",mac:"Shift-Alt-m",run:IT}].concat(BT);var Ry=typeof String.prototype.normalize=="function"?i=>i.normalize("NFKD"):i=>i,nn=class{constructor(e,t,n=0,r=e.length,s,o){this.test=o,this.value={from:0,to:0,precise:!1},this.done=!1,this.matches=[],this.buffer="",this.bufferPos=0,this.iter=e.iterRange(n,r),this.bufferStart=n,this.normalize=s?l=>s(Ry(l)):Ry,this.query=this.normalize(t)}peek(){if(this.bufferPos==this.buffer.length){if(this.bufferStart+=this.buffer.length,this.iter.next(),this.iter.done)return-1;this.bufferPos=0,this.buffer=this.iter.value}return Ze(this.buffer,this.bufferPos)}next(){for(;this.matches.length;)this.matches.pop();return this.nextOverlapping()}nextOverlapping(){for(;;){let e=this.peek();if(e<0)return this.done=!0,this;let t=io(e),n=this.bufferStart+this.bufferPos;this.bufferPos+=mt(e);let r=this.normalize(t);if(r.length)for(let s=0,o=n,l=!0;;s++){let a=r.charCodeAt(s),c=this.match(a,o,l,this.bufferPos+this.bufferStart,s==r.length-1);if(c)return this.value=c,this;if(s==r.length-1)break;l&&sthis.to&&(this.curLine=this.curLine.slice(0,this.to-this.curLineStart)),this.iter.next())}nextLine(){this.curLineStart=this.curLineStart+this.curLine.length+1,this.curLineStart>this.to?this.curLine="":this.getLine(0)}next(){for(let e=this.matchPos-this.curLineStart;;){this.re.lastIndex=e;let t=this.matchPos<=this.to&&this.re.exec(this.curLine);if(t){let n=this.curLineStart+t.index,r=n+t[0].length;if(this.matchPos=Xc(this.text,r+(n==r?1:0)),n==this.curLineStart+this.curLine.length&&this.nextLine(),(nthis.value.to)&&(!this.test||this.test(n,r,t)))return this.value={from:n,to:r,precise:!0,match:t},this;e=this.matchPos-this.curLineStart}else if(this.curLineStart+this.curLine.length=n||r.to<=t){let l=new i(t,e.sliceString(t,n));return zd.set(e,l),l}if(r.from==t&&r.to==n)return r;let{text:s,from:o}=r;return o>t&&(s=e.sliceString(t,o)+s,o=t),r.to=this.to?this.to:this.text.lineAt(e).to}next(){for(;;){let e=this.re.lastIndex=this.matchPos-this.flat.from,t=this.re.exec(this.flat.text);if(t&&!t[0]&&t.index==e&&(this.re.lastIndex=e+1,t=this.re.exec(this.flat.text)),t){let n=this.flat.from+t.index,r=n+t[0].length;if((this.flat.to>=this.to||t.index+t[0].length<=this.flat.text.length-10)&&(!this.test||this.test(n,r,t)))return this.value={from:n,to:r,precise:!0,match:t},this.matchPos=Xc(this.text,r+(n==r?1:0)),this}if(this.flat.to==this.to)return this.done=!0,this;this.flat=Mc.get(this.text,this.flat.from,this.chunkEnd(this.flat.from+this.flat.text.length*2))}}};typeof Symbol<"u"&&(Ac.prototype[Symbol.iterator]=Rc.prototype[Symbol.iterator]=function(){return this});function VT(i){try{return new RegExp(i,Fd),!0}catch{return!1}}function Xc(i,e){if(e>=i.length)return e;let t=i.lineAt(e),n;for(;e=56320&&n<57344;)e++;return e}var YT=i=>{let e=z1(i,"cm-goto-line");if(e){let o=e.dom.querySelector("input[type=text]");return o&&o.select(),!0}let{state:t}=i,n=String(t.doc.lineAt(i.state.selection.main.head).number),{close:r,result:s}=Y1(i,{class:"cm-goto-line",label:t.phrase("Go to line"),input:{type:"text",name:"line",value:n},focus:!0,submitLabel:t.phrase("go")});return s.then(o=>{let l=o&&/^([+-])?(\d+)?(:\d+)?(%)?$/.exec(o.elements.line.value);if(!l){i.dispatch({effects:r});return}let a=t.doc.lineAt(t.selection.main.head),[,c,h,f,u]=l,d=f?+f.slice(1):0,p=h?+h:a.number;if(h&&u){let b=p/100;c&&(b=b*(c=="-"?-1:1)+a.number/t.doc.lines),p=Math.round(t.doc.lines*b)}else h&&c&&(p=p*(c=="-"?-1:1)+a.number);let O=t.doc.line(Math.max(1,Math.min(t.doc.lines,p))),g=v.cursor(O.from+Math.max(0,Math.min(d,O.length)));i.dispatch({effects:[r,M.scrollIntoView(g.from,{y:"center"})],selection:g})}),!0},zT={highlightWordAroundCursor:!1,minSelectionLength:1,maxMatches:100,wholeWords:!1},_y=R.define({combine(i){return Ye(i,zT,{highlightWordAroundCursor:(e,t)=>e||t,minSelectionLength:Math.min,maxMatches:Math.min})}});function Ly(i){let e=[FT,GT];return i&&e.push(_y.of(i)),e}var WT=L.mark({class:"cm-selectionMatch"}),NT=L.mark({class:"cm-selectionMatch cm-selectionMatch-main"});function Xy(i,e,t,n){return(t==0||i(e.sliceDoc(t-1,t))!=ae.Word)&&(n==e.doc.length||i(e.sliceDoc(n,n+1))!=ae.Word)}function qT(i,e,t,n){return i(e.sliceDoc(t,t+1))==ae.Word&&i(e.sliceDoc(n-1,n))==ae.Word}var GT=se.fromClass(class{constructor(i){this.decorations=this.getDeco(i)}update(i){(i.selectionSet||i.docChanged||i.viewportChanged)&&(this.decorations=this.getDeco(i.view))}getDeco(i){let e=i.state.facet(_y),{state:t}=i,n=t.selection;if(n.ranges.length>1)return L.none;let r=n.main,s,o=null;if(r.empty){if(!e.highlightWordAroundCursor)return L.none;let a=t.wordAt(r.head);if(!a)return L.none;o=t.charCategorizer(r.head),s=t.sliceDoc(a.from,a.to)}else{let a=r.to-r.from;if(a200)return L.none;if(e.wholeWords){if(s=t.sliceDoc(r.from,r.to),o=t.charCategorizer(r.head),!(Xy(o,t,r.from,r.to)&&qT(o,t,r.from,r.to)))return L.none}else if(s=t.sliceDoc(r.from,r.to),!s)return L.none}let l=[];for(let a of i.visibleRanges){let c=new nn(t.doc,s,a.from,a.to);for(;!c.next().done;){let{from:h,to:f}=c.value;if((!o||Xy(o,t,h,f))&&(r.empty&&h<=r.from&&f>=r.to?l.push(NT.range(h,f)):(h>=r.to||f<=r.from)&&l.push(WT.range(h,f)),l.length>e.maxMatches))return L.none}}return L.set(l)}},{decorations:i=>i.decorations}),FT=M.baseTheme({".cm-selectionMatch":{backgroundColor:"#99ff7780"},".cm-searchMatch .cm-selectionMatch":{backgroundColor:"transparent"}}),HT=({state:i,dispatch:e})=>{let{selection:t}=i,n=v.create(t.ranges.map(r=>i.wordAt(r.head)||v.cursor(r.head)),t.mainIndex);return n.eq(t)?!1:(e(i.update({selection:n})),!0)};function KT(i,e){let{main:t,ranges:n}=i.selection,r=i.wordAt(t.head),s=r&&r.from==t.from&&r.to==t.to;for(let o=!1,l=new nn(i.doc,e,n[n.length-1].to);;)if(l.next(),l.done){if(o)return null;l=new nn(i.doc,e,0,Math.max(0,n[n.length-1].from-1)),o=!0}else{if(o&&n.some(a=>a.from==l.value.from))continue;if(s){let a=i.wordAt(l.value.from);if(!a||a.from!=l.value.from||a.to!=l.value.to)continue}return l.value}}var JT=({state:i,dispatch:e})=>{let{ranges:t}=i.selection;if(t.some(s=>s.from===s.to))return HT({state:i,dispatch:e});let n=i.sliceDoc(t[0].from,t[0].to);if(i.selection.ranges.some(s=>i.sliceDoc(s.from,s.to)!=n))return!1;let r=KT(i,n);return r?(e(i.update({selection:i.selection.addRange(v.range(r.from,r.to),!1),effects:M.scrollIntoView(r.to)})),!0):!1},hs=R.define({combine(i){return Ye(i,{top:!1,caseSensitive:!1,literal:!1,regexp:!1,wholeWord:!1,createPanel:e=>new Gd(e),scrollToMatch:e=>M.scrollIntoView(e)})}});var Ec=class{constructor(e){this.search=e.search,this.caseSensitive=!!e.caseSensitive,this.literal=!!e.literal,this.regexp=!!e.regexp,this.replace=e.replace||"",this.valid=!!this.search&&(!this.regexp||VT(this.search)),this.unquoted=this.unquote(this.search),this.wholeWord=!!e.wholeWord,this.test=e.test}unquote(e){return this.literal?e:e.replace(/\\([nrt\\])/g,(t,n)=>n=="n"?` +`:n=="r"?"\r":n=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp&&this.wholeWord==e.wholeWord&&this.test==e.test}create(){return this.regexp?new Nd(this):new Wd(this)}getCursor(e,t=0,n){let r=e.doc?e:ne.create({doc:e});return n==null&&(n=r.doc.length),this.regexp?cs(this,r,t,n):as(this,r,t,n)}},Zc=class{constructor(e){this.spec=e}};function e2(i,e,t){return(n,r,s,o)=>{if(t&&!t(n,r,s,o))return!1;let l=n>=o&&r<=o+s.length?s.slice(n-o,r-o):e.doc.sliceString(n,r);return i(l,e,n,r)}}function as(i,e,t,n){let r;return i.wholeWord&&(r=t2(e.doc,e.charCategorizer(e.selection.main.head))),i.test&&(r=e2(i.test,e,r)),new nn(e.doc,i.unquoted,t,n,i.caseSensitive?void 0:s=>s.toLowerCase(),r)}function t2(i,e){return(t,n,r,s)=>((s>t||s+r.length=t)return null;r.push(n.value)}return r}highlight(e,t,n,r){let s=as(this.spec,e,Math.max(0,t-this.spec.unquoted.length),Math.min(n+this.spec.unquoted.length,e.doc.length));for(;!s.next().done;)r(s.value.from,s.value.to)}};function i2(i,e,t){return(n,r,s)=>(!t||t(n,r,s))&&i(s[0],e,n,r)}function cs(i,e,t,n){let r;return i.wholeWord&&(r=n2(e.charCategorizer(e.selection.main.head))),i.test&&(r=i2(i.test,e,r)),new Ac(e.doc,i.search,{ignoreCase:!i.caseSensitive,test:r},t,n)}function _c(i,e){return i.slice(be(i,e,!1),e)}function Lc(i,e){return i.slice(e,be(i,e))}function n2(i){return(e,t,n)=>!n[0].length||(i(_c(n.input,n.index))!=ae.Word||i(Lc(n.input,n.index))!=ae.Word)&&(i(Lc(n.input,n.index+n[0].length))!=ae.Word||i(_c(n.input,n.index+n[0].length))!=ae.Word)}var Nd=class extends Zc{nextMatch(e,t,n){let r=cs(this.spec,e,n,e.doc.length).next();return r.done&&(r=cs(this.spec,e,0,t).next()),r.done?null:r.value}prevMatchInRange(e,t,n){for(let r=1;;r++){let s=Math.max(t,n-r*1e4),o=cs(this.spec,e,s,n),l=null;for(;!o.next().done;)l=o.value;if(l&&(s==t||l.from>s+10))return l;if(s==t)return null}}prevMatch(e,t,n){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,n,e.doc.length)}getReplacement(e){return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g,(t,n)=>{if(n=="&")return e.match[0];if(n=="$")return"$";for(let r=n.length;r>0;r--){let s=+n.slice(0,r);if(s>0&&s=t)return null;r.push(n.value)}return r}highlight(e,t,n,r){let s=cs(this.spec,e,Math.max(0,t-250),Math.min(n+250,e.doc.length));for(;!s.next().done;)r(s.value.from,s.value.to)}},Lo=Y.define(),Hd=Y.define(),tn=de.define({create(i){return new _o(qd(i).create(),null)},update(i,e){for(let t of e.effects)t.is(Lo)?i=new _o(t.value.create(),i.panel):t.is(Hd)&&(i=new _o(i.query,t.value?Kd:null));return i},provide:i=>Vn.from(i,e=>e.panel)});var _o=class{constructor(e,t){this.query=e,this.panel=t}},r2=L.mark({class:"cm-searchMatch"}),s2=L.mark({class:"cm-searchMatch cm-searchMatch-selected"}),o2=se.fromClass(class{constructor(i){this.view=i,this.decorations=this.highlight(i.state.field(tn))}update(i){let e=i.state.field(tn);(e!=i.startState.field(tn)||i.docChanged||i.selectionSet||i.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:i,panel:e}){if(!e||!i.spec.valid)return L.none;let{view:t}=this,n=new lt;for(let r=0,s=t.visibleRanges,o=s.length;rs[r+1].from-500;)a=s[++r].to;i.highlight(t.state,l,a,(c,h)=>{let f=t.state.selection.ranges.some(u=>u.from==c&&u.to==h);n.add(c,h,f?s2:r2)})}return n.finish()}},{decorations:i=>i.decorations});function Do(i){return e=>{let t=e.state.field(tn,!1);return t&&t.query.spec.valid?i(e,t):Iy(e)}}var Dc=Do((i,{query:e})=>{let{to:t}=i.state.selection.main,n=e.nextMatch(i.state,t,t);if(!n)return!1;let r=v.single(n.from,n.to),s=i.state.facet(hs);return i.dispatch({selection:r,effects:[Jd(i,n),s.scrollToMatch(r.main,i)],userEvent:"select.search"}),jy(i),!0}),jc=Do((i,{query:e})=>{let{state:t}=i,{from:n}=t.selection.main,r=e.prevMatch(t,n,n);if(!r)return!1;let s=v.single(r.from,r.to),o=i.state.facet(hs);return i.dispatch({selection:s,effects:[Jd(i,r),o.scrollToMatch(s.main,i)],userEvent:"select.search"}),jy(i),!0}),l2=Do((i,{query:e})=>{let t=e.matchAll(i.state,1e3);return!t||!t.length?!1:(i.dispatch({selection:v.create(t.map(n=>v.range(n.from,n.to))),userEvent:"select.search.matches"}),!0)}),a2=({state:i,dispatch:e})=>{let t=i.selection;if(t.ranges.length>1||t.main.empty)return!1;let{from:n,to:r}=t.main,s=[],o=0;for(let l=new nn(i.doc,i.sliceDoc(n,r));!l.next().done;){if(s.length>1e3)return!1;l.value.from==n&&(o=s.length),s.push(v.range(l.value.from,l.value.to))}return e(i.update({selection:v.create(s,o),userEvent:"select.search.matches"})),!0},Ey=Do((i,{query:e})=>{let{state:t}=i,{from:n,to:r}=t.selection.main;if(t.readOnly)return!1;let s=e.nextMatch(t,n,n);if(!s)return!1;let o=s,l=[],a,c,h=[];o.precise?o.from==n&&o.to==r&&(c=t.toText(e.getReplacement(o)),l.push({from:o.from,to:o.to,insert:c}),o=e.nextMatch(t,o.from,o.to),h.push(M.announce.of(t.phrase("replaced match on line $",t.doc.lineAt(n).number)+"."))):o=e.nextMatch(t,o.from,o.to);let f=i.state.changes(l);return o&&(a=v.single(o.from,o.to).map(f),h.push(Jd(i,o)),h.push(t.facet(hs).scrollToMatch(a.main,i))),i.dispatch({changes:f,selection:a,effects:h,userEvent:"input.replace"}),!0}),c2=Do((i,{query:e})=>{if(i.state.readOnly)return!1;let t=[];for(let r of e.matchAll(i.state,1e9)){let{from:s,to:o,precise:l}=r;l&&t.push({from:s,to:o,insert:e.getReplacement(r)})}if(!t.length)return!1;let n=i.state.phrase("replaced $ matches",t.length)+".";return i.dispatch({changes:t,effects:M.announce.of(n),userEvent:"input.replace.all"}),!0});function Kd(i){return i.state.facet(hs).createPanel(i)}function qd(i,e){var t,n,r,s,o;let l=i.selection.main,a=l.empty||l.to>l.from+100?"":i.sliceDoc(l.from,l.to);if(e&&!a)return e;let c=i.facet(hs);return new Ec({search:((t=e?.literal)!==null&&t!==void 0?t:c.literal)?a:a.replace(/\n/g,"\\n"),caseSensitive:(n=e?.caseSensitive)!==null&&n!==void 0?n:c.caseSensitive,literal:(r=e?.literal)!==null&&r!==void 0?r:c.literal,regexp:(s=e?.regexp)!==null&&s!==void 0?s:c.regexp,wholeWord:(o=e?.wholeWord)!==null&&o!==void 0?o:c.wholeWord})}function Dy(i){let e=is(i,Kd);return e&&e.dom.querySelector("[main-field]")}function jy(i){let e=Dy(i);e&&e==i.root.activeElement&&e.select()}var Iy=i=>{let e=i.state.field(tn,!1);if(e&&e.panel){let t=Dy(i);if(t&&t!=i.root.activeElement){let n=qd(i.state,e.query.spec);n.valid&&i.dispatch({effects:Lo.of(n)}),t.focus(),t.select()}}else i.dispatch({effects:[Hd.of(!0),e?Lo.of(qd(i.state,e.query.spec)):Y.appendConfig.of(f2)]});return!0},Uy=i=>{let e=i.state.field(tn,!1);if(!e||!e.panel)return!1;let t=is(i,Kd);return t&&t.dom.contains(i.root.activeElement)&&i.focus(),i.dispatch({effects:Hd.of(!1)}),!0},By=[{key:"Mod-f",run:Iy,scope:"editor search-panel"},{key:"F3",run:Dc,shift:jc,scope:"editor search-panel",preventDefault:!0},{key:"Mod-g",run:Dc,shift:jc,scope:"editor search-panel",preventDefault:!0},{key:"Escape",run:Uy,scope:"editor search-panel"},{key:"Mod-Shift-l",run:a2},{key:"Mod-Alt-g",run:YT},{key:"Mod-d",run:JT,preventDefault:!0}],Gd=class{constructor(e){this.view=e;let t=this.query=e.state.field(tn).query.spec;this.commit=this.commit.bind(this),this.searchField=te("input",{value:t.search,placeholder:xt(e,"Find"),"aria-label":xt(e,"Find"),class:"cm-textfield",name:"search",form:"","main-field":"true",onchange:this.commit,onkeyup:this.commit}),this.replaceField=te("input",{value:t.replace,placeholder:xt(e,"Replace"),"aria-label":xt(e,"Replace"),class:"cm-textfield",name:"replace",form:"",onchange:this.commit,onkeyup:this.commit}),this.caseField=te("input",{type:"checkbox",name:"case",form:"",checked:t.caseSensitive,onchange:this.commit}),this.reField=te("input",{type:"checkbox",name:"re",form:"",checked:t.regexp,onchange:this.commit}),this.wordField=te("input",{type:"checkbox",name:"word",form:"",checked:t.wholeWord,onchange:this.commit});function n(r,s,o){return te("button",{class:"cm-button",name:r,onclick:s,type:"button"},o)}this.dom=te("div",{onkeydown:r=>this.keydown(r),class:"cm-search"},[this.searchField,n("next",()=>Dc(e),[xt(e,"next")]),n("prev",()=>jc(e),[xt(e,"previous")]),n("select",()=>l2(e),[xt(e,"all")]),te("label",null,[this.caseField,xt(e,"match case")]),te("label",null,[this.reField,xt(e,"regexp")]),te("label",null,[this.wordField,xt(e,"by word")]),...e.state.readOnly?[]:[te("br"),this.replaceField,n("replace",()=>Ey(e),[xt(e,"replace")]),n("replaceAll",()=>c2(e),[xt(e,"replace all")])],te("button",{name:"close",onclick:()=>Uy(e),"aria-label":xt(e,"close"),type:"button"},["\xD7"])])}commit(){let e=new Ec({search:this.searchField.value,caseSensitive:this.caseField.checked,regexp:this.reField.checked,wholeWord:this.wordField.checked,replace:this.replaceField.value});e.eq(this.query)||(this.query=e,this.view.dispatch({effects:Lo.of(e)}))}keydown(e){C1(this.view,e,"search-panel")?e.preventDefault():e.keyCode==13&&e.target==this.searchField?(e.preventDefault(),(e.shiftKey?jc:Dc)(this.view)):e.keyCode==13&&e.target==this.replaceField&&(e.preventDefault(),Ey(this.view))}update(e){for(let t of e.transactions)for(let n of t.effects)n.is(Lo)&&!n.value.eq(this.query)&&this.setQuery(n.value)}setQuery(e){this.query=e,this.searchField.value=e.search,this.replaceField.value=e.replace,this.caseField.checked=e.caseSensitive,this.reField.checked=e.regexp,this.wordField.checked=e.wholeWord}mount(){this.searchField.select()}get pos(){return 80}get top(){return this.view.state.facet(hs).top}};function xt(i,e){return i.state.phrase(e)}var Cc=30,Tc=/[\s\.,:;?!]/;function Jd(i,{from:e,to:t}){let n=i.state.doc.lineAt(e),r=i.state.doc.lineAt(t).to,s=Math.max(n.from,e-Cc),o=Math.min(r,t+Cc),l=i.state.sliceDoc(s,o);if(s!=n.from){for(let a=0;al.length-Cc;a--)if(!Tc.test(l[a-1])&&Tc.test(l[a])){l=l.slice(0,a);break}}return M.announce.of(`${i.state.phrase("current match")}. ${l} ${i.state.phrase("on line")} ${n.number}.`)}var h2=M.baseTheme({".cm-panel.cm-search":{padding:"2px 6px 4px",position:"relative","& [name=close]":{position:"absolute",top:"0",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",padding:0,margin:0},"& input, & button, & label":{margin:".2em .6em .2em 0"},"& input[type=checkbox]":{marginRight:".2em"},"& label":{fontSize:"80%",whiteSpace:"pre"}},"&light .cm-searchMatch":{backgroundColor:"#ffff0054"},"&dark .cm-searchMatch":{backgroundColor:"#00ffff8a"},"&light .cm-searchMatch-selected":{backgroundColor:"#ff6a0054"},"&dark .cm-searchMatch-selected":{backgroundColor:"#ff00ff8a"}}),f2=[tn,at.low(o2),h2];var fs=class{constructor(e,t,n,r){this.state=e,this.pos=t,this.explicit=n,this.view=r,this.abortListeners=[],this.abortOnDocChange=!1}tokenBefore(e){let t=ee(this.state).resolveInner(this.pos,-1);for(;t&&e.indexOf(t.name)<0;)t=t.parent;return t?{from:t.from,to:this.pos,text:this.state.sliceDoc(t.from,this.pos),type:t.type}:null}matchBefore(e){let t=this.state.doc.lineAt(this.pos),n=Math.max(t.from,this.pos-250),r=t.text.slice(n-t.from,this.pos-t.from),s=r.search(Ky(e,!1));return s<0?null:{from:n+s,to:this.pos,text:r.slice(s)}}get aborted(){return this.abortListeners==null}addEventListener(e,t,n){e=="abort"&&this.abortListeners&&(this.abortListeners.push(t),n&&n.onDocChange&&(this.abortOnDocChange=!0))}};function Vy(i){let e=Object.keys(i).join(""),t=/\w/.test(e);return t&&(e=e.replace(/\w/g,"")),`[${t?"\\w":""}${e.replace(/[^\w\s]/g,"\\$&")}]`}function u2(i){let e=Object.create(null),t=Object.create(null);for(let{label:r}of i){e[r[0]]=!0;for(let s=1;stypeof r=="string"?{label:r}:r),[t,n]=e.every(r=>/^\w+$/.test(r.label))?[/\w*$/,/\w+$/]:u2(e);return r=>{let s=r.matchBefore(n);return s||r.explicit?{from:s?s.from:r.pos,options:e,validFor:t}:null}}function Hy(i,e){return t=>{for(let n=ee(t.state).resolveInner(t.pos,-1);n;n=n.parent){if(i.indexOf(n.name)>-1)return null;if(n.type.isTop)break}return e(t)}}var Uc=class{constructor(e,t,n,r){this.completion=e,this.source=t,this.match=n,this.score=r}};function Kn(i){return i.selection.main.from}function Ky(i,e){var t;let{source:n}=i,r=e&&n[0]!="^",s=n[n.length-1]!="$";return!r&&!s?i:new RegExp(`${r?"^":""}(?:${n})${s?"$":""}`,(t=i.flags)!==null&&t!==void 0?t:i.ignoreCase?"i":"")}var dp=we.define();function d2(i,e,t,n){let{main:r}=i.selection,s=t-r.from,o=n-r.from;return{...i.changeByRange(l=>{if(l!=r&&t!=n&&i.sliceDoc(l.from+s,l.from+o)!=i.sliceDoc(t,n))return{range:l};let a=i.toText(e);return{changes:{from:l.from+s,to:n==r.from?l.to:l.from+o,insert:a},range:v.cursor(l.from+s+a.length)}}),scrollIntoView:!0,userEvent:"input.complete"}}var Yy=new WeakMap;function p2(i){if(!Array.isArray(i))return i;let e=Yy.get(i);return e||Yy.set(i,e=up(i)),e}var Bc=Y.define(),jo=Y.define(),np=class{constructor(e){this.pattern=e,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[],this.score=0,this.matched=[];for(let t=0;t=48&&C<=57||C>=97&&C<=122?2:C>=65&&C<=90?1:0:(T=io(C))!=T.toLowerCase()?1:T!=T.toUpperCase()?2:0;(!w||y==1&&g||X==0&&y!=0)&&(t[f]==C||n[f]==C&&(u=!0)?o[f++]=w:o.length&&(b=!1)),X=y,w+=mt(C)}return f==a&&o[0]==0&&b?this.result(-100+(u?-200:0),o,e):d==a&&p==0?this.ret(-200-e.length+(O==e.length?0:-100),[0,O]):l>-1?this.ret(-700-e.length,[l,l+this.pattern.length]):d==a?this.ret(-900-e.length,[p,O]):f==a?this.result(-100+(u?-200:0)+-700+(b?0:-1100),o,e):t.length==2?null:this.result((r[0]?-700:0)+-200+-1100,r,e)}result(e,t,n){let r=[],s=0;for(let o of t){let l=o+(this.astral?mt(Ze(n,o)):1);s&&r[s-1]==o?r[s-1]=l:(r[s++]=o,r[s++]=l)}return this.ret(e-n.length,r)}},rp=class{constructor(e){this.pattern=e,this.matched=[],this.score=0,this.folded=e.toLowerCase()}match(e){if(e.length!1,activateOnTypingDelay:100,selectOnOpen:!0,override:null,closeOnBlur:!0,maxRenderedOptions:100,defaultKeymap:!0,tooltipClass:()=>"",optionClass:()=>"",aboveCursor:!1,icons:!0,addToOptions:[],positionInfo:O2,filterStrict:!1,compareCompletions:(e,t)=>(e.sortText||e.label).localeCompare(t.sortText||t.label),interactionDelay:75,updateSyncTime:100},{defaultKeymap:(e,t)=>e&&t,closeOnBlur:(e,t)=>e&&t,icons:(e,t)=>e&&t,tooltipClass:(e,t)=>n=>zy(e(n),t(n)),optionClass:(e,t)=>n=>zy(e(n),t(n)),addToOptions:(e,t)=>e.concat(t),filterStrict:(e,t)=>e||t})}});function zy(i,e){return i?e?i+" "+e:i:e}function O2(i,e,t,n,r,s){let o=i.textDirection==re.RTL,l=o,a=!1,c="top",h,f,u=e.left-r.left,d=r.right-e.right,p=n.right-n.left,O=n.bottom-n.top;if(l&&u=O||w>e.top?h=t.bottom-e.top:(c="bottom",h=e.bottom-t.top)}let g=(e.bottom-e.top)/s.offsetHeight,b=(e.right-e.left)/s.offsetWidth;return{style:`${c}: ${h/g}px; max-width: ${f/b}px`,class:"cm-completionInfo-"+(a?o?"left-narrow":"right-narrow":l?"left":"right")}}var pp=Y.define();function g2(i){let e=i.addToOptions.slice();return i.icons&&e.push({render(t){let n=document.createElement("div");return n.classList.add("cm-completionIcon"),t.type&&n.classList.add(...t.type.split(/\s+/g).map(r=>"cm-completionIcon-"+r)),n.setAttribute("aria-hidden","true"),n},position:20}),e.push({render(t,n,r,s){let o=document.createElement("span");o.className="cm-completionLabel";let l=t.displayLabel||t.label,a=0;for(let c=0;ca&&o.appendChild(document.createTextNode(l.slice(a,h)));let u=o.appendChild(document.createElement("span"));u.appendChild(document.createTextNode(l.slice(h,f))),u.className="cm-completionMatchedText",a=f}return at.position-n.position).map(t=>t.render)}function ep(i,e,t){if(i<=t)return{from:0,to:i};if(e<0&&(e=0),e<=i>>1){let r=Math.floor(e/t);return{from:r*t,to:(r+1)*t}}let n=Math.ceil((i-e)/t);return{from:i-n*t,to:i-(n-1)*t}}var sp=class{constructor(e,t,n){this.view=e,this.stateField=t,this.applyCompletion=n,this.info=null,this.infoDestroy=null,this.placeInfoReq={read:()=>this.measureInfo(),write:a=>this.placeInfo(a),key:this},this.space=null,this.currentClass="";let r=e.state.field(t),{options:s,selected:o}=r.open,l=e.state.facet(Ae);this.optionContent=g2(l),this.optionClass=l.optionClass,this.tooltipClass=l.tooltipClass,this.range=ep(s.length,o,l.maxRenderedOptions),this.dom=document.createElement("div"),this.dom.className="cm-tooltip-autocomplete",this.updateTooltipClass(e.state),this.dom.addEventListener("mousedown",a=>{let{options:c}=e.state.field(t).open;for(let h=a.target,f;h&&h!=this.dom;h=h.parentNode)if(h.nodeName=="LI"&&(f=/-(\d+)$/.exec(h.id))&&+f[1]this.list.lastChild.getBoundingClientRect().bottom?this.range.to:null;h!=null&&(e.dispatch({effects:pp.of(h)}),a.preventDefault())}}),this.dom.addEventListener("focusout",a=>{let c=e.state.field(this.stateField,!1);c&&c.tooltip&&e.state.facet(Ae).closeOnBlur&&a.relatedTarget!=e.contentDOM&&e.dispatch({effects:jo.of(null)})}),this.showOptions(s,r.id)}mount(){this.updateSel()}showOptions(e,t){this.list&&this.list.remove(),this.list=this.dom.appendChild(this.createListBox(e,t,this.range)),this.list.addEventListener("scroll",()=>{this.info&&this.view.requestMeasure(this.placeInfoReq)})}update(e){var t;let n=e.state.field(this.stateField),r=e.startState.field(this.stateField);if(this.updateTooltipClass(e.state),n!=r){let{options:s,selected:o,disabled:l}=n.open;(!r.open||r.open.options!=s)&&(this.range=ep(s.length,o,e.state.facet(Ae).maxRenderedOptions),this.showOptions(s,n.id)),this.updateSel(),l!=((t=r.open)===null||t===void 0?void 0:t.disabled)&&this.dom.classList.toggle("cm-tooltip-autocomplete-disabled",!!l)}}updateTooltipClass(e){let t=this.tooltipClass(e);if(t!=this.currentClass){for(let n of this.currentClass.split(" "))n&&this.dom.classList.remove(n);for(let n of t.split(" "))n&&this.dom.classList.add(n);this.currentClass=t}}positioned(e){this.space=e,this.info&&this.view.requestMeasure(this.placeInfoReq)}updateSel(){let e=this.view.state.field(this.stateField),t=e.open;(t.selected>-1&&t.selected=this.range.to)&&(this.range=ep(t.options.length,t.selected,this.view.state.facet(Ae).maxRenderedOptions),this.showOptions(t.options,e.id));let n=this.updateSelectedOption(t.selected);if(n){this.destroyInfo();let{completion:r}=t.options[t.selected],{info:s}=r;if(!s)return;let o=typeof s=="string"?document.createTextNode(s):s(r);if(!o)return;"then"in o?o.then(l=>{l&&this.view.state.field(this.stateField,!1)==e&&this.addInfoPane(l,r)}).catch(l=>_e(this.view.state,l,"completion info")):(this.addInfoPane(o,r),n.setAttribute("aria-describedby",this.info.id))}}addInfoPane(e,t){this.destroyInfo();let n=this.info=document.createElement("div");if(n.className="cm-tooltip cm-completionInfo",n.id="cm-completionInfo-"+Math.floor(Math.random()*65535).toString(16),e.nodeType!=null)n.appendChild(e),this.infoDestroy=null;else{let{dom:r,destroy:s}=e;n.appendChild(r),this.infoDestroy=s||null}this.dom.appendChild(n),this.view.requestMeasure(this.placeInfoReq)}updateSelectedOption(e){let t=null;for(let n=this.list.firstChild,r=this.range.from;n;n=n.nextSibling,r++)n.nodeName!="LI"||!n.id?r--:r==e?n.hasAttribute("aria-selected")||(n.setAttribute("aria-selected","true"),t=n):n.hasAttribute("aria-selected")&&(n.removeAttribute("aria-selected"),n.removeAttribute("aria-describedby"));return t&&b2(this.list,t),t}measureInfo(){let e=this.dom.querySelector("[aria-selected]");if(!e||!this.info)return null;let t=this.dom.getBoundingClientRect(),n=this.info.getBoundingClientRect(),r=e.getBoundingClientRect(),s=this.space;if(!s){let o=this.dom.ownerDocument.documentElement;s={left:0,top:0,right:o.clientWidth,bottom:o.clientHeight}}return r.top>Math.min(s.bottom,t.bottom)-10||r.bottom{o.target==r&&o.preventDefault()});let s=null;for(let o=n.from;on.from||n.from==0))if(s=u,typeof c!="string"&&c.header)r.appendChild(c.header(c));else{let d=r.appendChild(document.createElement("completion-section"));d.textContent=u}}let h=r.appendChild(document.createElement("li"));h.id=t+"-"+o,h.setAttribute("role","option");let f=this.optionClass(l);f&&(h.className=f);for(let u of this.optionContent){let d=u(l,this.view.state,this.view,a);d&&h.appendChild(d)}}return n.from&&r.classList.add("cm-completionListIncompleteTop"),n.tonew sp(t,i,e)}function b2(i,e){let t=i.getBoundingClientRect(),n=e.getBoundingClientRect(),r=t.height/i.offsetHeight;n.topt.bottom&&(i.scrollTop+=(n.bottom-t.bottom)/r)}function Wy(i){return(i.boost||0)*100+(i.apply?10:0)+(i.info?5:0)+(i.type?1:0)}function y2(i,e){let t=[],n=null,r=null,s=h=>{t.push(h);let{section:f}=h.completion;if(f){n||(n=[]);let u=typeof f=="string"?f:f.name;n.some(d=>d.name==u)||n.push(typeof f=="string"?{name:u}:f)}},o=e.facet(Ae);for(let h of i)if(h.hasResult()){let f=h.result.getMatch;if(h.result.filter===!1)for(let u of h.result.options)s(new Uc(u,h.source,f?f(u):[],1e9-t.length));else{let u=e.sliceDoc(h.from,h.to),d,p=o.filterStrict?new rp(u):new np(u);for(let O of h.result.options)if(d=p.match(O.label)){let g=O.displayLabel?f?f(O,d.matched):[]:d.matched,b=d.score+(O.boost||0);if(s(new Uc(O,h.source,g,b)),typeof O.section=="object"&&O.section.rank==="dynamic"){let{name:w}=O.section;r||(r=Object.create(null)),r[w]=Math.max(b,r[w]||-1e9)}}}}if(n){let h=Object.create(null),f=0,u=(d,p)=>(d.rank==="dynamic"&&p.rank==="dynamic"?r[p.name]-r[d.name]:0)||(typeof d.rank=="number"?d.rank:1e9)-(typeof p.rank=="number"?p.rank:1e9)||(d.nameu.score-f.score||c(f.completion,u.completion))){let f=h.completion;!a||a.label!=f.label||a.detail!=f.detail||a.type!=null&&f.type!=null&&a.type!=f.type||a.apply!=f.apply||a.boost!=f.boost?l.push(h):Wy(h.completion)>Wy(a)&&(l[l.length-1]=h),a=h.completion}return l}var op=class i{constructor(e,t,n,r,s,o){this.options=e,this.attrs=t,this.tooltip=n,this.timestamp=r,this.selected=s,this.disabled=o}setSelected(e,t){return e==this.selected||e>=this.options.length?this:new i(this.options,Ny(t,e),this.tooltip,this.timestamp,e,this.disabled)}static build(e,t,n,r,s,o){if(r&&!o&&e.some(c=>c.isPending))return r.setDisabled();let l=y2(e,t);if(!l.length)return r&&e.some(c=>c.isPending)?r.setDisabled():null;let a=t.facet(Ae).selectOnOpen?0:-1;if(r&&r.selected!=a&&r.selected!=-1){let c=r.options[r.selected].completion;for(let h=0;hh.hasResult()?Math.min(c,h.from):c,1e8),create:v2,above:s.aboveCursor},r?r.timestamp:Date.now(),a,!1)}map(e){return new i(this.options,this.attrs,{...this.tooltip,pos:e.mapPos(this.tooltip.pos)},this.timestamp,this.selected,this.disabled)}setDisabled(){return new i(this.options,this.attrs,this.tooltip,this.timestamp,this.selected,!0)}},lp=class i{constructor(e,t,n){this.active=e,this.id=t,this.open=n}static start(){return new i(k2,"cm-ac-"+Math.floor(Math.random()*2e6).toString(36),null)}update(e){let{state:t}=e,n=t.facet(Ae),s=(n.override||t.languageDataAt("autocomplete",Kn(t)).map(p2)).map(a=>(this.active.find(h=>h.source==a)||new Pi(a,this.active.some(h=>h.state!=0)?1:0)).update(e,n));s.length==this.active.length&&s.every((a,c)=>a==this.active[c])&&(s=this.active);let o=this.open,l=e.effects.some(a=>a.is(Op));o&&e.docChanged&&(o=o.map(e.changes)),e.selection||s.some(a=>a.hasResult()&&e.changes.touchesRange(a.from,a.to))||!S2(s,this.active)||l?o=op.build(s,t,this.id,o,n,l):o&&o.disabled&&!s.some(a=>a.isPending)&&(o=null),!o&&s.every(a=>!a.isPending)&&s.some(a=>a.hasResult())&&(s=s.map(a=>a.hasResult()?new Pi(a.source,0):a));for(let a of e.effects)a.is(pp)&&(o=o&&o.setSelected(a.value,this.id));return s==this.active&&o==this.open?this:new i(s,this.id,o)}get tooltip(){return this.open?this.open.tooltip:null}get attrs(){return this.open?this.open.attrs:this.active.length?x2:w2}};function S2(i,e){if(i==e)return!0;for(let t=0,n=0;;){for(;t-1&&(t["aria-activedescendant"]=i+"-"+e),t}var k2=[];function Jy(i,e){if(i.isUserEvent("input.complete")){let n=i.annotation(dp);if(n&&e.activateOnCompletion(n))return 12}let t=i.isUserEvent("input.type");return t&&e.activateOnTyping?5:t?1:i.isUserEvent("delete.backward")?2:i.selection?8:i.docChanged?16:0}var Pi=class i{constructor(e,t,n=!1){this.source=e,this.state=t,this.explicit=n}hasResult(){return!1}get isPending(){return this.state==1}update(e,t){let n=Jy(e,t),r=this;(n&8||n&16&&this.touches(e))&&(r=new i(r.source,0)),n&4&&r.state==0&&(r=new i(this.source,1)),r=r.updateFor(e,n);for(let s of e.effects)if(s.is(Bc))r=new i(r.source,1,s.value);else if(s.is(jo))r=new i(r.source,0);else if(s.is(Op))for(let o of s.value)o.source==r.source&&(r=o);return r}updateFor(e,t){return this.map(e.changes)}map(e){return this}touches(e){return e.changes.touchesRange(Kn(e.state))}},Vc=class i extends Pi{constructor(e,t,n,r,s,o){super(e,3,t),this.limit=n,this.result=r,this.from=s,this.to=o}hasResult(){return!0}updateFor(e,t){var n;if(!(t&3))return this.map(e.changes);let r=this.result;r.map&&!e.changes.empty&&(r=r.map(r,e.changes));let s=e.changes.mapPos(this.from),o=e.changes.mapPos(this.to,1),l=Kn(e.state);if(l>o||!r||t&2&&(Kn(e.startState)==this.from||lt.map(e))}}),ut=de.define({create(){return lp.start()},update(i,e){return i.update(e)},provide:i=>[xo.from(i,e=>e.tooltip),M.contentAttributes.from(i,e=>e.attrs)]});function gp(i,e){let t=e.completion.apply||e.completion.label,n=i.state.field(ut).active.find(r=>r.source==e.source);return n instanceof Vc?(typeof t=="string"?i.dispatch({...d2(i.state,t,n.from,n.to),annotations:dp.of(e.completion)}):t(i,e.completion,n.from,n.to),!0):!1}var v2=m2(ut,gp);function Ic(i,e="option"){return t=>{let n=t.state.field(ut,!1);if(!n||!n.open||n.open.disabled||Date.now()-n.open.timestamp-1?n.open.selected+r*(i?1:-1):i?0:o-1;return l<0?l=e=="page"?0:o-1:l>=o&&(l=e=="page"?o-1:0),t.dispatch({effects:pp.of(l)}),!0}}var $2=i=>{let e=i.state.field(ut,!1);return i.state.readOnly||!e||!e.open||e.open.selected<0||e.open.disabled||Date.now()-e.open.timestampi.state.field(ut,!1)?(i.dispatch({effects:Bc.of(!0)}),!0):!1,P2=i=>{let e=i.state.field(ut,!1);return!e||!e.active.some(t=>t.state!=0)?!1:(i.dispatch({effects:jo.of(null)}),!0)},ap=class{constructor(e,t){this.active=e,this.context=t,this.time=Date.now(),this.updates=[],this.done=void 0}},C2=50,T2=1e3,A2=se.fromClass(class{constructor(i){this.view=i,this.debounceUpdate=-1,this.running=[],this.debounceAccept=-1,this.pendingStart=!1,this.composing=0;for(let e of i.state.field(ut).active)e.isPending&&this.startQuery(e)}update(i){let e=i.state.field(ut),t=i.state.facet(Ae);if(!i.selectionSet&&!i.docChanged&&i.startState.field(ut)==e)return;let n=i.transactions.some(s=>{let o=Jy(s,t);return o&8||(s.selection||s.docChanged)&&!(o&3)});for(let s=0;sC2&&Date.now()-o.time>T2){for(let l of o.context.abortListeners)try{l()}catch(a){_e(this.view.state,a)}o.context.abortListeners=null,this.running.splice(s--,1)}else o.updates.push(...i.transactions)}this.debounceUpdate>-1&&clearTimeout(this.debounceUpdate),i.transactions.some(s=>s.effects.some(o=>o.is(Bc)))&&(this.pendingStart=!0);let r=this.pendingStart?50:t.activateOnTypingDelay;if(this.debounceUpdate=e.active.some(s=>s.isPending&&!this.running.some(o=>o.active.source==s.source))?setTimeout(()=>this.startUpdate(),r):-1,this.composing!=0)for(let s of i.transactions)s.isUserEvent("input.type")?this.composing=2:this.composing==2&&s.selection&&(this.composing=3)}startUpdate(){this.debounceUpdate=-1,this.pendingStart=!1;let{state:i}=this.view,e=i.field(ut);for(let t of e.active)t.isPending&&!this.running.some(n=>n.active.source==t.source)&&this.startQuery(t);this.running.length&&e.open&&e.open.disabled&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(Ae).updateSyncTime))}startQuery(i){let{state:e}=this.view,t=Kn(e),n=new fs(e,t,i.explicit,this.view),r=new ap(i,n);this.running.push(r),Promise.resolve(i.source(n)).then(s=>{r.context.aborted||(r.done=s||null,this.scheduleAccept())},s=>{this.view.dispatch({effects:jo.of(null)}),_e(this.view.state,s)})}scheduleAccept(){this.running.every(i=>i.done!==void 0)?this.accept():this.debounceAccept<0&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(Ae).updateSyncTime))}accept(){var i;this.debounceAccept>-1&&clearTimeout(this.debounceAccept),this.debounceAccept=-1;let e=[],t=this.view.state.facet(Ae),n=this.view.state.field(ut);for(let r=0;rl.source==s.active.source);if(o&&o.isPending)if(s.done==null){let l=new Pi(s.active.source,0);for(let a of s.updates)l=l.update(a,t);l.isPending||e.push(l)}else this.startQuery(o)}(e.length||n.open&&n.open.disabled)&&this.view.dispatch({effects:Op.of(e)})}},{eventHandlers:{blur(i){let e=this.view.state.field(ut,!1);if(e&&e.tooltip&&this.view.state.facet(Ae).closeOnBlur){let t=e.open&&td(this.view,e.open.tooltip);(!t||!t.dom.contains(i.relatedTarget))&&setTimeout(()=>this.view.dispatch({effects:jo.of(null)}),10)}},compositionstart(){this.composing=1},compositionend(){this.composing==3&&setTimeout(()=>this.view.dispatch({effects:Bc.of(!1)}),20),this.composing=0}}}),M2=typeof navigator=="object"&&/Win/.test(navigator.platform),R2=at.highest(M.domEventHandlers({keydown(i,e){let t=e.state.field(ut,!1);if(!t||!t.open||t.open.disabled||t.open.selected<0||i.key.length>1||i.ctrlKey&&!(M2&&i.altKey)||i.metaKey)return!1;let n=t.open.options[t.open.selected],r=t.active.find(o=>o.source==n.source),s=n.completion.commitCharacters||r.result.commitCharacters;return s&&s.indexOf(i.key)>-1&&gp(e,n),!1}})),eS=M.baseTheme({".cm-tooltip.cm-tooltip-autocomplete":{"& > ul":{fontFamily:"monospace",whiteSpace:"nowrap",overflow:"hidden auto",maxWidth_fallback:"700px",maxWidth:"min(700px, 95vw)",minWidth:"250px",maxHeight:"10em",height:"100%",listStyle:"none",margin:0,padding:0,"& > li, & > completion-section":{padding:"1px 3px",lineHeight:1.2},"& > li":{overflowX:"hidden",textOverflow:"ellipsis",cursor:"pointer"},"& > completion-section":{display:"list-item",borderBottom:"1px solid silver",paddingLeft:"0.5em",opacity:.7}}},"&light .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#17c",color:"white"},"&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#777"},"&dark .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#347",color:"white"},"&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#444"},".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after":{content:'"\xB7\xB7\xB7"',opacity:.5,display:"block",textAlign:"center",cursor:"pointer"},".cm-tooltip.cm-completionInfo":{position:"absolute",padding:"3px 9px",width:"max-content",maxWidth:"400px",boxSizing:"border-box",whiteSpace:"pre-line"},".cm-completionInfo.cm-completionInfo-left":{right:"100%"},".cm-completionInfo.cm-completionInfo-right":{left:"100%"},".cm-completionInfo.cm-completionInfo-left-narrow":{right:"30px"},".cm-completionInfo.cm-completionInfo-right-narrow":{left:"30px"},"&light .cm-snippetField":{backgroundColor:"#00000022"},"&dark .cm-snippetField":{backgroundColor:"#ffffff22"},".cm-snippetFieldPosition":{verticalAlign:"text-top",width:0,height:"1.15em",display:"inline-block",margin:"0 -0.7px -.7em",borderLeft:"1.4px dotted #888"},".cm-completionMatchedText":{textDecoration:"underline"},".cm-completionDetail":{marginLeft:"0.5em",fontStyle:"italic"},".cm-completionIcon":{fontSize:"90%",width:".8em",display:"inline-block",textAlign:"center",paddingRight:".6em",opacity:"0.6",boxSizing:"content-box"},".cm-completionIcon-function, .cm-completionIcon-method":{"&:after":{content:"'\u0192'"}},".cm-completionIcon-class":{"&:after":{content:"'\u25CB'"}},".cm-completionIcon-interface":{"&:after":{content:"'\u25CC'"}},".cm-completionIcon-variable":{"&:after":{content:"'\u{1D465}'"}},".cm-completionIcon-constant":{"&:after":{content:"'\u{1D436}'"}},".cm-completionIcon-type":{"&:after":{content:"'\u{1D461}'"}},".cm-completionIcon-enum":{"&:after":{content:"'\u222A'"}},".cm-completionIcon-property":{"&:after":{content:"'\u25A1'"}},".cm-completionIcon-keyword":{"&:after":{content:"'\u{1F511}\uFE0E'"}},".cm-completionIcon-namespace":{"&:after":{content:"'\u25A2'"}},".cm-completionIcon-text":{"&:after":{content:"'abc'",fontSize:"50%",verticalAlign:"middle"}}}),cp=class{constructor(e,t,n,r){this.field=e,this.line=t,this.from=n,this.to=r}},hp=class i{constructor(e,t,n){this.field=e,this.from=t,this.to=n}map(e){let t=e.mapPos(this.from,-1,$e.TrackDel),n=e.mapPos(this.to,1,$e.TrackDel);return t==null||n==null?null:new i(this.field,t,n)}},fp=class i{constructor(e,t){this.lines=e,this.fieldPositions=t}instantiate(e,t){let n=[],r=[t],s=e.doc.lineAt(t),o=/^\s*/.exec(s.text)[0];for(let a of this.lines){if(n.length){let c=o,h=/^\t*/.exec(a)[0].length;for(let f=0;fnew hp(a.field,r[a.line]+a.from,r[a.line]+a.to));return{text:n,ranges:l}}static parse(e){let t=[],n=[],r=[],s;for(let o of e.split(/\r\n?|\n/)){for(;s=/[#$]\{(?:(\d+)(?::([^{}]*))?|((?:\\[{}]|[^{}])*))\}/.exec(o);){let l=s[1]?+s[1]:null,a=s[2]||s[3]||"",c=-1;l===0&&(l=1e9);let h=a.replace(/\\[{}]/g,f=>f[1]);for(let f=0;f=c&&u.field++}for(let f of r)if(f.line==n.length&&f.from>s.index){let u=s[2]?3+(s[1]||"").length:2;f.from-=u,f.to-=u}r.push(new cp(c,n.length,s.index,s.index+h.length)),o=o.slice(0,s.index)+a+o.slice(s.index+s[0].length)}o=o.replace(/\\([{}])/g,(l,a,c)=>{for(let h of r)h.line==n.length&&h.from>c&&(h.from--,h.to--);return a}),n.push(o)}return new i(n,r)}},X2=L.widget({widget:new class extends De{toDOM(){let i=document.createElement("span");return i.className="cm-snippetFieldPosition",i}ignoreEvent(){return!1}}}),E2=L.mark({class:"cm-snippetField"}),us=class i{constructor(e,t){this.ranges=e,this.active=t,this.deco=L.set(e.map(n=>(n.from==n.to?X2:E2).range(n.from,n.to)),!0)}map(e){let t=[];for(let n of this.ranges){let r=n.map(e);if(!r)return null;t.push(r)}return new i(t,this.active)}selectionInsideField(e){return e.ranges.every(t=>this.ranges.some(n=>n.field==this.active&&n.from<=t.from&&n.to>=t.to))}},Bo=Y.define({map(i,e){return i&&i.map(e)}}),Z2=Y.define(),Io=de.define({create(){return null},update(i,e){for(let t of e.effects){if(t.is(Bo))return t.value;if(t.is(Z2)&&i)return new us(i.ranges,t.value)}return i&&e.docChanged&&(i=i.map(e.changes)),i&&e.selection&&!i.selectionInsideField(e.selection)&&(i=null),i},provide:i=>M.decorations.from(i,e=>e?e.deco:L.none)});function mp(i,e){return v.create(i.filter(t=>t.field==e).map(t=>v.range(t.from,t.to)))}function _2(i){let e=fp.parse(i);return(t,n,r,s)=>{let{text:o,ranges:l}=e.instantiate(t.state,r),{main:a}=t.state.selection,c={changes:{from:r,to:s==a.from?a.to:s,insert:q.of(o)},scrollIntoView:!0,annotations:n?[dp.of(n),xe.userEvent.of("input.complete")]:void 0};if(l.length&&(c.selection=mp(l,0)),l.some(h=>h.field>0)){let h=new us(l,0),f=c.effects=[Bo.of(h)];t.state.field(Io,!1)===void 0&&f.push(Y.appendConfig.of([Io,U2,B2,eS]))}t.dispatch(t.state.update(c))}}function tS(i){return({state:e,dispatch:t})=>{let n=e.field(Io,!1);if(!n||i<0&&n.active==0)return!1;let r=n.active+i,s=i>0&&!n.ranges.some(o=>o.field==r+i);return t(e.update({selection:mp(n.ranges,r),effects:Bo.of(s?null:new us(n.ranges,r)),scrollIntoView:!0})),!0}}var L2=({state:i,dispatch:e})=>i.field(Io,!1)?(e(i.update({effects:Bo.of(null)})),!0):!1,D2=tS(1),j2=tS(-1);var I2=[{key:"Tab",run:D2,shift:j2},{key:"Escape",run:L2}],qy=R.define({combine(i){return i.length?i[0]:I2}}),U2=at.highest(Qi.compute([qy],i=>i.facet(qy)));function it(i,e){return{...e,apply:_2(i)}}var B2=M.domEventHandlers({mousedown(i,e){let t=e.state.field(Io,!1),n;if(!t||(n=e.posAtCoords({x:i.clientX,y:i.clientY}))==null)return!1;let r=t.ranges.find(s=>s.from<=n&&s.to>=n);return!r||r.field==t.active?!1:(e.dispatch({selection:mp(t.ranges,r.field),effects:Bo.of(t.ranges.some(s=>s.field>r.field)?new us(t.ranges,r.field):null),scrollIntoView:!0}),!0)}});var Uo={brackets:["(","[","{","'",'"'],before:")]}:;>",stringPrefixes:[]},Hn=Y.define({map(i,e){let t=e.mapPos(i,-1,$e.TrackAfter);return t??void 0}}),bp=new class extends Ct{};bp.startSide=1;bp.endSide=-1;var iS=de.define({create(){return G.empty},update(i,e){if(i=i.map(e.changes),e.selection){let t=e.state.doc.lineAt(e.selection.main.head);i=i.update({filter:n=>n>=t.from&&n<=t.to})}for(let t of e.effects)t.is(Hn)&&(i=i.update({add:[bp.range(t.value,t.value+1)]}));return i}});function nS(){return[Y2,iS]}var ip="()[]{}<>\xAB\xBB\xBB\xAB\uFF3B\uFF3D\uFF5B\uFF5D";function rS(i){for(let e=0;e{if((V2?i.composing:i.compositionStarted)||i.state.readOnly)return!1;let r=i.state.selection.main;if(n.length>2||n.length==2&&mt(Ze(n,0))==1||e!=r.from||t!=r.to)return!1;let s=W2(i.state,n);return s?(i.dispatch(s),!0):!1}),z2=({state:i,dispatch:e})=>{if(i.readOnly)return!1;let n=sS(i,i.selection.main.head).brackets||Uo.brackets,r=null,s=i.changeByRange(o=>{if(o.empty){let l=N2(i.doc,o.head);for(let a of n)if(a==l&&Yc(i.doc,o.head)==rS(Ze(a,0)))return{changes:{from:o.head-a.length,to:o.head+a.length},range:v.cursor(o.head-a.length)}}return{range:r=o}});return r||e(i.update(s,{scrollIntoView:!0,userEvent:"delete.backward"})),!r},oS=[{key:"Backspace",run:z2}];function W2(i,e){let t=sS(i,i.selection.main.head),n=t.brackets||Uo.brackets;for(let r of n){let s=rS(Ze(r,0));if(e==r)return s==r?F2(i,r,n.indexOf(r+r+r)>-1,t):q2(i,r,s,t.before||Uo.before);if(e==s&&lS(i,i.selection.main.from))return G2(i,r,s)}return null}function lS(i,e){let t=!1;return i.field(iS).between(0,i.doc.length,n=>{n==e&&(t=!0)}),t}function Yc(i,e){let t=i.sliceString(e,e+2);return t.slice(0,mt(Ze(t,0)))}function N2(i,e){let t=i.sliceString(e-2,e);return mt(Ze(t,0))==t.length?t:t.slice(1)}function q2(i,e,t,n){let r=null,s=i.changeByRange(o=>{if(!o.empty)return{changes:[{insert:e,from:o.from},{insert:t,from:o.to}],effects:Hn.of(o.to+e.length),range:v.range(o.anchor+e.length,o.head+e.length)};let l=Yc(i.doc,o.head);return!l||/\s/.test(l)||n.indexOf(l)>-1?{changes:{insert:e+t,from:o.head},effects:Hn.of(o.head+e.length),range:v.cursor(o.head+e.length)}:{range:r=o}});return r?null:i.update(s,{scrollIntoView:!0,userEvent:"input.type"})}function G2(i,e,t){let n=null,r=i.changeByRange(s=>s.empty&&Yc(i.doc,s.head)==t?{changes:{from:s.head,to:s.head+t.length,insert:t},range:v.cursor(s.head+t.length)}:n={range:s});return n?null:i.update(r,{scrollIntoView:!0,userEvent:"input.type"})}function F2(i,e,t,n){let r=n.stringPrefixes||Uo.stringPrefixes,s=null,o=i.changeByRange(l=>{if(!l.empty)return{changes:[{insert:e,from:l.from},{insert:e,from:l.to}],effects:Hn.of(l.to+e.length),range:v.range(l.anchor+e.length,l.head+e.length)};let a=l.head,c=Yc(i.doc,a),h;if(c==e){if(Gy(i,a))return{changes:{insert:e+e,from:a},effects:Hn.of(a+e.length),range:v.cursor(a+e.length)};if(lS(i,a)){let u=t&&i.sliceDoc(a,a+e.length*3)==e+e+e?e+e+e:e;return{changes:{from:a,to:a+u.length,insert:u},range:v.cursor(a+u.length)}}}else{if(t&&i.sliceDoc(a-2*e.length,a)==e+e&&(h=Fy(i,a-2*e.length,r))>-1&&Gy(i,h))return{changes:{insert:e+e+e+e,from:a},effects:Hn.of(a+e.length),range:v.cursor(a+e.length)};if(i.charCategorizer(a)(c)!=ae.Word&&Fy(i,a,r)>-1&&!H2(i,a,e,r))return{changes:{insert:e+e,from:a},effects:Hn.of(a+e.length),range:v.cursor(a+e.length)}}return{range:s=l}});return s?null:i.update(o,{scrollIntoView:!0,userEvent:"input.type"})}function Gy(i,e){let t=ee(i).resolveInner(e+1);return t.parent&&t.from==e}function H2(i,e,t,n){let r=ee(i).resolveInner(e,-1),s=n.reduce((o,l)=>Math.max(o,l.length),0);for(let o=0;o<5;o++){let l=i.sliceDoc(r.from,Math.min(r.to,r.from+t.length+s)),a=l.indexOf(t);if(!a||a>-1&&n.indexOf(l.slice(0,a))>-1){let h=r.firstChild;for(;h&&h.from==r.from&&h.to-h.from>t.length+a;){if(i.sliceDoc(h.to-t.length,h.to)==t)return!1;h=h.firstChild}return!0}let c=r.to==e&&r.parent;if(!c)break;r=c}return!1}function Fy(i,e,t){let n=i.charCategorizer(e);if(n(i.sliceDoc(e-1,e))!=ae.Word)return e;for(let r of t){let s=e-r.length;if(i.sliceDoc(s,e)==r&&n(i.sliceDoc(s-1,s))!=ae.Word)return s}return-1}function aS(i={}){return[R2,ut,Ae.of(i),A2,K2,eS]}var yp=[{key:"Ctrl-Space",run:tp},{mac:"Alt-`",run:tp},{mac:"Alt-i",run:tp},{key:"Escape",run:P2},{key:"ArrowDown",run:Ic(!0)},{key:"ArrowUp",run:Ic(!1)},{key:"PageDown",run:Ic(!0,"page")},{key:"PageUp",run:Ic(!1,"page")},{key:"Enter",run:$2}],K2=at.highest(Qi.computeN([Ae],i=>i.facet(Ae).defaultKeymap?[yp]:[]));var Wc=class{constructor(e,t,n){this.from=e,this.to=t,this.diagnostic=n}},Jn=class i{constructor(e,t,n){this.diagnostics=e,this.panel=t,this.selected=n}static init(e,t,n){let r=n.facet(Vo).markerFilter;r&&(e=r(e,n));let s=e.slice().sort((d,p)=>d.from-p.from||d.to-p.to),o=new lt,l=[],a=0,c=n.doc.iter(),h=0,f=n.doc.length;for(let d=0;;){let p=d==s.length?null:s[d];if(!p&&!l.length)break;let O,g;if(l.length)O=a,g=l.reduce((x,X)=>Math.min(x,X.to),p&&p.from>O?p.from:1e8);else{if(O=p.from,O>f)break;g=p.to,l.push(p),d++}for(;dx.from||x.to==O))l.push(x),d++,g=Math.min(x.to,g);else{g=Math.min(x.from,g);break}}g=Math.min(g,f);let b=!1;if(l.some(x=>x.from==O&&(x.to==g||g==f))&&(b=O==g,!b&&g-O<10)){let x=O-(h+c.value.length);x>0&&(c.next(x),h=O);for(let X=O;;){if(X>=g){b=!0;break}if(!c.lineBreak&&h+c.value.length>X)break;X=h+c.value.length,h+=c.value.length,c.next()}}let w=cA(l);if(b)o.add(O,O,L.widget({widget:new Sp(w),diagnostics:l.slice()}));else{let x=l.reduce((X,C)=>C.markClass?X+" "+C.markClass:X,"");o.add(O,g,L.mark({class:"cm-lintRange cm-lintRange-"+w+x,diagnostics:l.slice(),inclusiveEnd:l.some(X=>X.to>g)}))}if(a=g,a==f)break;for(let x=0;x{if(!(e&&o.diagnostics.indexOf(e)<0))if(!n)n=new Wc(r,s,e||o.diagnostics[0]);else{if(o.diagnostics.indexOf(n.diagnostic)<0)return!1;n=new Wc(n.from,s,n.diagnostic)}}),n}function J2(i,e){let t=e.pos,n=e.end||t,r=i.state.facet(Vo).hideOn(i,t,n);if(r!=null)return r;let s=i.startState.doc.lineAt(e.pos);return!!(i.effects.some(o=>o.is(fS))||i.changes.touchesRange(s.from,Math.max(s.to,n)))}function eA(i,e){return i.field(wt,!1)?e:e.concat(Y.appendConfig.of(hA))}var fS=Y.define(),xp=Y.define(),uS=Y.define(),wt=de.define({create(){return new Jn(L.none,null,null)},update(i,e){if(e.docChanged&&i.diagnostics.size){let t=i.diagnostics.map(e.changes),n=null,r=i.panel;if(i.selected){let s=e.changes.mapPos(i.selected.from,1);n=rn(t,i.selected.diagnostic,s)||rn(t,null,s)}!t.size&&r&&e.state.facet(Vo).autoPanel&&(r=null),i=new Jn(t,r,n)}for(let t of e.effects)if(t.is(fS)){let n=e.state.facet(Vo).autoPanel?t.value.length?Yo.open:null:i.panel;i=Jn.init(t.value,n,e.state)}else t.is(xp)?i=new Jn(i.diagnostics,t.value?Yo.open:null,i.selected):t.is(uS)&&(i=new Jn(i.diagnostics,i.panel,t.value));return i},provide:i=>[Vn.from(i,e=>e.panel),M.decorations.from(i,e=>e.diagnostics)]});var tA=L.mark({class:"cm-lintRange cm-lintRange-active"});function iA(i,e,t){let{diagnostics:n}=i.state.field(wt),r,s=-1,o=-1;n.between(e-(t<0?1:0),e+(t>0?1:0),(a,c,{spec:h})=>{if(e>=a&&e<=c&&(a==c||(e>a||t>0)&&(eOS(i,t,!1)))}var rA=i=>{let e=i.state.field(wt,!1);(!e||!e.panel)&&i.dispatch({effects:eA(i.state,[xp.of(!0)])});let t=is(i,Yo.open);return t&&t.dom.querySelector(".cm-panel-lint ul").focus(),!0},cS=i=>{let e=i.state.field(wt,!1);return!e||!e.panel?!1:(i.dispatch({effects:xp.of(!1)}),!0)},sA=i=>{let e=i.state.field(wt,!1);if(!e)return!1;let t=i.state.selection.main,n=rn(e.diagnostics,null,t.to+1);return!n&&(n=rn(e.diagnostics,null,0),!n||n.from==t.from&&n.to==t.to)?!1:(i.dispatch({selection:{anchor:n.from,head:n.to},scrollIntoView:!0}),B1(i,n.from,1,{tooltip:gS,until:r=>r.docChanged||r.newSelection.main.headn.to}),!0)};var dS=[{key:"Mod-Shift-m",run:rA,preventDefault:!0},{key:"F8",run:sA}];var Vo=R.define({combine(i){return{sources:i.map(e=>e.source).filter(e=>e!=null),...Ye(i.map(e=>e.config),{delay:750,markerFilter:null,tooltipFilter:null,needsRefresh:null,hideOn:()=>null},{delay:Math.max,markerFilter:hS,tooltipFilter:hS,needsRefresh:(e,t)=>e?t?n=>e(n)||t(n):e:t,hideOn:(e,t)=>e?t?(n,r,s)=>e(n,r,s)||t(n,r,s):e:t,autoPanel:(e,t)=>e||t})}}});function hS(i,e){return i?e?(t,n)=>e(i(t,n),n):i:e}function pS(i){let e=[];if(i)e:for(let{name:t}of i){for(let n=0;ns.toLowerCase()==r.toLowerCase())){e.push(r);continue e}}e.push("")}return e}function OS(i,e,t){var n;let r=t?pS(e.actions):[];return te("li",{class:"cm-diagnostic cm-diagnostic-"+e.severity},te("span",{class:"cm-diagnosticText"},e.renderMessage?e.renderMessage(i):e.message),(n=e.actions)===null||n===void 0?void 0:n.map((s,o)=>{let l=!1,a=d=>{if(d.preventDefault(),l)return;l=!0;let p=rn(i.state.field(wt).diagnostics,e);p&&s.apply(i,p.from,p.to)},{name:c}=s,h=r[o]?c.indexOf(r[o]):-1,f=h<0?c:[c.slice(0,h),te("u",c.slice(h,h+1)),c.slice(h+1)],u=s.markClass?" "+s.markClass:"";return te("button",{type:"button",class:"cm-diagnosticAction"+u,onclick:a,onmousedown:a,"aria-label":` Action: ${c}${h<0?"":` (access key "${r[o]})"`}.`},f)}),e.source&&te("div",{class:"cm-diagnosticSource"},e.source))}var Sp=class extends De{constructor(e){super(),this.sev=e}eq(e){return e.sev==this.sev}toDOM(){return te("span",{class:"cm-lintPoint cm-lintPoint-"+this.sev})}},Nc=class{constructor(e,t){this.diagnostic=t,this.id="item_"+Math.floor(Math.random()*4294967295).toString(16),this.dom=OS(e,t,!0),this.dom.id=this.id,this.dom.setAttribute("role","option")}},Yo=class i{constructor(e){this.view=e,this.items=[];let t=r=>{if(!(r.ctrlKey||r.altKey||r.metaKey)){if(r.keyCode==27)cS(this.view),this.view.focus();else if(r.keyCode==38||r.keyCode==33)this.moveSelection((this.selectedIndex-1+this.items.length)%this.items.length);else if(r.keyCode==40||r.keyCode==34)this.moveSelection((this.selectedIndex+1)%this.items.length);else if(r.keyCode==36)this.moveSelection(0);else if(r.keyCode==35)this.moveSelection(this.items.length-1);else if(r.keyCode==13)this.view.focus();else if(r.keyCode>=65&&r.keyCode<=90&&this.selectedIndex>=0){let{diagnostic:s}=this.items[this.selectedIndex],o=pS(s.actions);for(let l=0;l{for(let s=0;scS(this.view)},"\xD7")),this.update()}get selectedIndex(){let e=this.view.state.field(wt).selected;if(!e)return-1;for(let t=0;t{for(let h of c.diagnostics){if(o.has(h))continue;o.add(h);let f=-1,u;for(let d=n;dn&&(this.items.splice(n,f-n),r=!0)),t&&u.diagnostic==t.diagnostic?u.dom.hasAttribute("aria-selected")||(u.dom.setAttribute("aria-selected","true"),s=u):u.dom.hasAttribute("aria-selected")&&u.dom.removeAttribute("aria-selected"),n++}});n({sel:s.dom.getBoundingClientRect(),panel:this.list.getBoundingClientRect()}),write:({sel:l,panel:a})=>{let c=a.height/this.list.offsetHeight;l.topa.bottom&&(this.list.scrollTop+=(l.bottom-a.bottom)/c)}})):this.selectedIndex<0&&this.list.removeAttribute("aria-activedescendant"),r&&this.sync()}sync(){let e=this.list.firstChild;function t(){let n=e;e=n.nextSibling,n.remove()}for(let n of this.items)if(n.dom.parentNode==this.list){for(;e!=n.dom;)t();e=n.dom.nextSibling}else this.list.insertBefore(n.dom,e);for(;e;)t()}moveSelection(e){if(this.selectedIndex<0)return;let t=this.view.state.field(wt),n=rn(t.diagnostics,this.items[e].diagnostic);n&&this.view.dispatch({selection:{anchor:n.from,head:n.to},scrollIntoView:!0,effects:uS.of(n)})}static open(e){return new i(e)}};function oA(i,e='viewBox="0 0 40 40"'){return`url('data:image/svg+xml,${encodeURIComponent(i)}')`}function zc(i){return oA(``,'width="6" height="3"')}var lA=M.baseTheme({".cm-diagnostic":{padding:"3px 6px 3px 8px",marginLeft:"-1px",display:"block",whiteSpace:"pre-wrap"},".cm-diagnostic-error":{borderLeft:"5px solid #d11"},".cm-diagnostic-warning":{borderLeft:"5px solid orange"},".cm-diagnostic-info":{borderLeft:"5px solid #999"},".cm-diagnostic-hint":{borderLeft:"5px solid #66d"},".cm-diagnosticAction":{font:"inherit",border:"none",padding:"2px 4px",backgroundColor:"#444",color:"white",borderRadius:"3px",marginLeft:"8px",cursor:"pointer"},".cm-diagnosticSource":{fontSize:"70%",opacity:.7},".cm-lintRange":{backgroundPosition:"left bottom",backgroundRepeat:"repeat-x",paddingBottom:"0.7px"},".cm-lintRange-error":{backgroundImage:zc("#f11")},".cm-lintRange-warning":{backgroundImage:zc("orange")},".cm-lintRange-info":{backgroundImage:zc("#999")},".cm-lintRange-hint":{backgroundImage:zc("#66d")},".cm-lintRange-active":{backgroundColor:"#ffdd9980"},".cm-tooltip-lint":{padding:0,margin:0},".cm-lintPoint":{position:"relative","&:after":{content:'""',position:"absolute",bottom:0,left:"-2px",borderLeft:"3px solid transparent",borderRight:"3px solid transparent",borderBottom:"4px solid #d11"}},".cm-lintPoint-warning":{"&:after":{borderBottomColor:"orange"}},".cm-lintPoint-info":{"&:after":{borderBottomColor:"#999"}},".cm-lintPoint-hint":{"&:after":{borderBottomColor:"#66d"}},".cm-panel.cm-panel-lint":{position:"relative","& ul":{maxHeight:"100px",overflowY:"auto","& [aria-selected]":{backgroundColor:"#ddd","& u":{textDecoration:"underline"}},"&:focus [aria-selected]":{background_fallback:"#bdf",backgroundColor:"Highlight",color_fallback:"white",color:"HighlightText"},"& u":{textDecoration:"none"},padding:0,margin:0},"& [name=close]":{position:"absolute",top:"0",right:"2px",background:"inherit",border:"none",font:"inherit",padding:0,margin:0}},"&dark .cm-lintRange-active":{backgroundColor:"#86714a80"},"&dark .cm-panel.cm-panel-lint ul":{"& [aria-selected]":{backgroundColor:"#2e343e"}}});function aA(i){return i=="error"?4:i=="warning"?3:i=="info"?2:1}function cA(i){let e="hint",t=1;for(let n of i){let r=aA(n.severity);r>t&&(t=r,e=n.severity)}return e}var gS=U1(iA,{hideOn:J2}),hA=[wt,M.decorations.compute([wt],i=>{let{selected:e,panel:t}=i.field(wt);return!e||!t||e.from==e.to?L.none:L.set([tA.range(e.from,e.to)])}),gS,lA];var fA=[G1(),F1(),_1(),ty(),_b(),R1(),Z1(),ne.allowMultipleSelections.of(!0),Cb(),Db(jb,{fallback:!0}),Vb(),nS(),aS(),D1(),j1(),L1(),Ly(),Qi.of([...oS,...My,...By,...sy,...Xb,...yp,...dS])];var Fc=class i{static create(e,t,n,r,s){let o=r+(r<<8)+e+(t<<4)|0;return new i(e,t,n,o,s,[],[])}constructor(e,t,n,r,s,o,l){this.type=e,this.value=t,this.from=n,this.hash=r,this.end=s,this.children=o,this.positions=l,this.hashProp=[[U.contextHash,r]]}addChild(e,t){e.prop(U.contextHash)!=this.hash&&(e=new J(e.type,e.children,e.positions,e.length,this.hashProp)),this.children.push(e),this.positions.push(t)}toTree(e,t=this.end){let n=this.children.length-1;return n>=0&&(t=Math.max(t,this.positions[n]+this.children[n].length+this.from)),new J(e.types[this.type],this.children,this.positions,t-this.from).balance({makeTree:(r,s,o)=>new J(Oe.none,r,s,o,this.hashProp)})}},A;(function(i){i[i.Document=1]="Document",i[i.CodeBlock=2]="CodeBlock",i[i.FencedCode=3]="FencedCode",i[i.Blockquote=4]="Blockquote",i[i.HorizontalRule=5]="HorizontalRule",i[i.BulletList=6]="BulletList",i[i.OrderedList=7]="OrderedList",i[i.ListItem=8]="ListItem",i[i.ATXHeading1=9]="ATXHeading1",i[i.ATXHeading2=10]="ATXHeading2",i[i.ATXHeading3=11]="ATXHeading3",i[i.ATXHeading4=12]="ATXHeading4",i[i.ATXHeading5=13]="ATXHeading5",i[i.ATXHeading6=14]="ATXHeading6",i[i.SetextHeading1=15]="SetextHeading1",i[i.SetextHeading2=16]="SetextHeading2",i[i.HTMLBlock=17]="HTMLBlock",i[i.LinkReference=18]="LinkReference",i[i.Paragraph=19]="Paragraph",i[i.CommentBlock=20]="CommentBlock",i[i.ProcessingInstructionBlock=21]="ProcessingInstructionBlock",i[i.Escape=22]="Escape",i[i.Entity=23]="Entity",i[i.HardBreak=24]="HardBreak",i[i.Emphasis=25]="Emphasis",i[i.StrongEmphasis=26]="StrongEmphasis",i[i.Link=27]="Link",i[i.Image=28]="Image",i[i.InlineCode=29]="InlineCode",i[i.HTMLTag=30]="HTMLTag",i[i.Comment=31]="Comment",i[i.ProcessingInstruction=32]="ProcessingInstruction",i[i.Autolink=33]="Autolink",i[i.HeaderMark=34]="HeaderMark",i[i.QuoteMark=35]="QuoteMark",i[i.ListMark=36]="ListMark",i[i.LinkMark=37]="LinkMark",i[i.EmphasisMark=38]="EmphasisMark",i[i.CodeMark=39]="CodeMark",i[i.CodeText=40]="CodeText",i[i.CodeInfo=41]="CodeInfo",i[i.LinkTitle=42]="LinkTitle",i[i.LinkLabel=43]="LinkLabel",i[i.URL=44]="URL"})(A||(A={}));var Qp=class{constructor(e,t){this.start=e,this.content=t,this.marks=[],this.parsers=[]}},vp=class{constructor(){this.text="",this.baseIndent=0,this.basePos=0,this.depth=0,this.markers=[],this.pos=0,this.indent=0,this.next=-1}forward(){this.basePos>this.pos&&this.forwardInner()}forwardInner(){let e=this.skipSpace(this.basePos);this.indent=this.countIndent(e,this.pos,this.indent),this.pos=e,this.next=e==this.text.length?-1:this.text.charCodeAt(e)}skipSpace(e){return Wo(this.text,e)}reset(e){for(this.text=e,this.baseIndent=this.basePos=this.pos=this.indent=0,this.forwardInner(),this.depth=1;this.markers.length;)this.markers.pop()}moveBase(e){this.basePos=e,this.baseIndent=this.countIndent(e,this.pos,this.indent)}moveBaseColumn(e){this.baseIndent=e,this.basePos=this.findColumn(e)}addMarker(e){this.markers.push(e)}countIndent(e,t=0,n=0){for(let r=t;r=e.stack[t.depth+1].value+t.baseIndent)return!0;if(t.indent>=t.baseIndent+4)return!1;let n=(i.type==A.OrderedList?Lp:_p)(t,e,!1);return n>0&&(i.type!=A.BulletList||Zp(t,e,!1)<0)&&t.text.charCodeAt(t.pos+n-1)==i.value}var CS={[A.Blockquote](i,e,t){return t.next!=62?!1:(t.markers.push(K(A.QuoteMark,e.lineStart+t.pos,e.lineStart+t.pos+1)),t.moveBase(t.pos+(Zt(t.text.charCodeAt(t.pos+1))?2:1)),i.end=e.lineStart+t.text.length,!0)},[A.ListItem](i,e,t){return t.indent-1?!1:(t.moveBaseColumn(t.baseIndent+i.value),!0)},[A.OrderedList]:mS,[A.BulletList]:mS,[A.Document](){return!0}};function Zt(i){return i==32||i==9||i==10||i==13}function Wo(i,e=0){for(;et&&Zt(i.charCodeAt(e-1));)e--;return e}function TS(i){if(i.next!=96&&i.next!=126)return-1;let e=i.pos+1;for(;e-1&&i.depth==e.stack.length&&e.parser.leafBlockParsers.indexOf(LS.SetextHeading)>-1||n<3?-1:1}function MS(i,e){for(let t=i.stack.length-1;t>=0;t--)if(i.stack[t].type==e)return!0;return!1}function _p(i,e,t){return(i.next==45||i.next==43||i.next==42)&&(i.pos==i.text.length-1||Zt(i.text.charCodeAt(i.pos+1)))&&(!t||MS(e,A.BulletList)||i.skipSpace(i.pos+2)=48&&r<=57;){n++;if(n==i.text.length)return-1;r=i.text.charCodeAt(n)}return n==i.pos||n>i.pos+9||r!=46&&r!=41||ni.pos+1||i.next!=49)?-1:n+1-i.pos}function RS(i){if(i.next!=35)return-1;let e=i.pos+1;for(;e6?-1:t}function XS(i){if(i.next!=45&&i.next!=61||i.indent>=i.baseIndent+4)return-1;let e=i.pos+1;for(;e/,ZS=/\?>/,Pp=[[/^<(?:script|pre|style)(?:\s|>|$)/i,/<\/(?:script|pre|style)>/i],[/^\s*/i.exec(n);if(s)return i.append(K(A.Comment,t,t+1+s[0].length));let o=/^\?[^]*?\?>/.exec(n);if(o)return i.append(K(A.ProcessingInstruction,t,t+1+o[0].length));let l=/^(?:![A-Z][^]*?>|!\[CDATA\[[^]*?\]\]>|\/\s*[a-zA-Z][\w-]*\s*>|\s*[a-zA-Z][\w-]*(\s+[a-zA-Z:_][\w-.:]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*(\/\s*)?>)/.exec(n);return l?i.append(K(A.HTMLTag,t,t+1+l[0].length)):-1},Emphasis(i,e,t){if(e!=95&&e!=42)return-1;let n=t+1;for(;i.char(n)==e;)n++;let r=i.slice(t-1,t),s=i.slice(n,n+1),o=Go.test(r),l=Go.test(s),a=/\s|^$/.test(r),c=/\s|^$/.test(s),h=!c&&(!l||a||o),f=!a&&(!o||c||l),u=h&&(e==42||!f||o),d=f&&(e==42||!h||l);return i.append(new nt(e==95?IS:US,t,n,(u?1:0)|(d?2:0)))},HardBreak(i,e,t){if(e==92&&i.char(t+1)==10)return i.append(K(A.HardBreak,t,t+2));if(e==32){let n=t+1;for(;i.char(n)==32;)n++;if(i.char(n)==10&&n>=t+2)return i.append(K(A.HardBreak,t,n+1))}return-1},Link(i,e,t){return e==91?i.append(new nt(er,t,t+1,1)):-1},Image(i,e,t){return e==33&&i.char(t+1)==91?i.append(new nt(Jc,t,t+2,1)):-1},LinkEnd(i,e,t){if(e!=93)return-1;for(let n=i.parts.length-1;n>=0;n--){let r=i.parts[n];if(r instanceof nt&&(r.type==er||r.type==Jc)){if(!r.side||i.skipSpace(r.to)==t&&!/[(\[]/.test(i.slice(t+1,t+2)))return i.parts[n]=null,-1;let s=i.takeContent(n),o=i.parts[n]=pA(i,s,r.type==er?A.Link:A.Image,r.from,t+1);if(r.type==er)for(let l=0;le?K(A.URL,e+t,s+t):s==i.length?null:!1}}function VS(i,e,t){let n=i.charCodeAt(e);if(n!=39&&n!=34&&n!=40)return!1;let r=n==40?41:n;for(let s=e+1,o=!1;s=this.end?-1:this.text.charCodeAt(e-this.offset)}get end(){return this.offset+this.text.length}slice(e,t){return this.text.slice(e-this.offset,t-this.offset)}append(e){return this.parts.push(e),e.to}addDelimiter(e,t,n,r,s){return this.append(new nt(e,t,n,(r?1:0)|(s?2:0)))}get hasOpenLink(){for(let e=this.parts.length-1;e>=0;e--){let t=this.parts[e];if(t instanceof nt&&(t.type==er||t.type==Jc))return!0}return!1}addElement(e){return this.append(e)}resolveMarkers(e){for(let n=e;n=e;a--){let O=this.parts[a];if(O instanceof nt&&O.side&1&&O.type==r.type&&!(s&&(r.side&1||O.side&2)&&(O.to-O.from+o)%3==0&&((O.to-O.from)%3||o%3))){l=O;break}}if(!l)continue;let c=r.type.resolve,h=[],f=l.from,u=r.to;if(s){let O=Math.min(2,l.to-l.from,o);f=l.to-O,u=r.from+O,c=O==1?"Emphasis":"StrongEmphasis"}l.type.mark&&h.push(this.elt(l.type.mark,f,l.to));for(let O=a+1;O=0;t--){let n=this.parts[t];if(n instanceof nt&&n.type==e&&n.side&1)return t}return null}takeContent(e){let t=this.resolveMarkers(e);return this.parts.length=e,t}getDelimiterAt(e){let t=this.parts[e];return t instanceof nt?t:null}skipSpace(e){return Wo(this.text,e-this.offset)+this.offset}elt(e,t,n,r){return typeof e=="string"?K(this.parser.getNodeType(e),t,n,r):new Kc(e,t)}};Fo.linkStart=er;Fo.imageStart=Jc;function Rp(i,e){if(!e.length)return i;if(!i.length)return e;let t=i.slice(),n=0;for(let r of e){for(;n(e?e-1:0))return!1;if(this.fragmentEnd<0){let s=this.fragment.to;for(;s>0&&this.input.read(s-1,s)!=` +`;)s--;this.fragmentEnd=s?s-1:0}let n=this.cursor;n||(n=this.cursor=this.fragment.tree.cursor(),n.firstChild());let r=e+this.fragment.offset;for(;n.to<=r;)if(!n.parent())return!1;for(;;){if(n.from>=r)return this.fragment.from<=t;if(!n.childAfter(r))return!1}}matches(e){let t=this.cursor.tree;return t&&t.prop(U.contextHash)==e}takeNodes(e){let t=this.cursor,n=this.fragment.offset,r=this.fragmentEnd-(this.fragment.openEnd?1:0),s=e.absoluteLineStart,o=s,l=e.block.children.length,a=o,c=l;for(;;){if(t.to-n>r){if(t.type.isAnonymous&&t.firstChild())continue;break}let h=zS(t.from-n,e.ranges);if(t.to-n<=e.ranges[e.rangeI].to)e.addNode(t.tree,h);else{let f=new J(e.parser.nodeSet.types[A.Paragraph],[],[],0,e.block.hashProp);e.reusePlaceholders.set(f,t.tree),e.addNode(f,h)}if(t.type.is("Block")&&(OA.indexOf(t.type.id)<0?(o=t.to-n,l=e.block.children.length):(o=a,l=c),a=t.to-n,c=e.block.children.length),!t.nextSibling())break}for(;e.block.children.length>l;)e.block.children.pop(),e.block.positions.pop();return o-s}};function zS(i,e){let t=i;for(let n=1;nqc[i]),Object.keys(qc).map(i=>LS[i]),Object.keys(qc),uA,CS,Object.keys(kp).map(i=>kp[i]),Object.keys(kp),[]);function mA(i,e,t){let n=[];for(let r=i.firstChild,s=e;;r=r.nextSibling){let o=r?r.from:t;if(o>s&&n.push({from:s,to:o}),!r)break;s=r.to}return n}function NS(i){let{codeParser:e,htmlParser:t}=i;return{wrap:hc((r,s)=>{let o=r.type.id;if(e&&(o==A.CodeBlock||o==A.FencedCode)){let l="";if(o==A.FencedCode){let c=r.node.getChild(A.CodeInfo);c&&(l=s.read(c.from,c.to))}let a=e(l);if(a)return{parser:a,overlay:c=>c.type.id==A.CodeText,bracketed:o==A.FencedCode}}else if(t&&(o==A.HTMLBlock||o==A.HTMLTag||o==A.CommentBlock))return{parser:t,overlay:mA(r.node,r.from,r.to)};return null})}}var bA={resolve:"Strikethrough",mark:"StrikethroughMark"},yA={defineNodes:[{name:"Strikethrough",style:{"Strikethrough/...":m.strikethrough}},{name:"StrikethroughMark",style:m.processingInstruction}],parseInline:[{name:"Strikethrough",parse(i,e,t){if(e!=126||i.char(t+1)!=126||i.char(t+2)==126)return-1;let n=i.slice(t-1,t),r=i.slice(t+2,t+3),s=/\s|^$/.test(n),o=/\s|^$/.test(r),l=Go.test(n),a=Go.test(r);return i.addDelimiter(bA,t,t+2,!o&&(!a||s||l),!s&&(!l||o||a))},after:"Emphasis"}]};function No(i,e,t=0,n,r=0){let s=0,o=!0,l=-1,a=-1,c=!1,h=()=>{n.push(i.elt("TableCell",r+l,r+a,i.parser.parseInline(e.slice(l,a),r+l)))};for(let f=t;f-1)&&s++,o=!1,n&&(l>-1&&h(),n.push(i.elt("TableDelimiter",f+r,f+r+1))),l=a=-1):(c||u!=32&&u!=9)&&(l<0&&(l=f),a=f+1),c=!c&&u==92}return l>-1&&(s++,n&&h()),s}function xS(i,e){for(let t=e;t\s]*\|?(\s*:?-+:?\s*\|)+(\s*:?-+:?\s*)?$/,eh=class{constructor(){this.rows=null}nextLine(e,t,n){if(this.rows==null){this.rows=!1;let r;if((t.next==45||t.next==58||t.next==124)&&qS.test(r=t.text.slice(t.pos))){let s=[];No(e,n.content,0,s,n.start)==No(e,r,0)&&(this.rows=[e.elt("TableHeader",n.start,n.start+n.content.length,s),e.elt("TableDelimiter",e.lineStart+t.pos,e.lineStart+t.text.length)])}}else if(this.rows){let r=[];No(e,t.text,t.pos,r,e.lineStart),this.rows.push(e.elt("TableRow",e.lineStart+t.pos,e.lineStart+t.text.length,r))}return!1}finish(e,t){return this.rows?(e.addLeafElement(t,e.elt("Table",t.start,t.start+t.content.length,this.rows)),!0):!1}},SA={defineNodes:[{name:"Table",block:!0},{name:"TableHeader",style:{"TableHeader/...":m.heading}},"TableRow",{name:"TableCell",style:m.content},{name:"TableDelimiter",style:m.processingInstruction}],parseBlock:[{name:"Table",leaf(i,e){return xS(e.content,0)?new eh:null},endLeaf(i,e,t){if(t.parsers.some(r=>r instanceof eh)||!xS(e.text,e.basePos))return!1;let n=i.peekLine();return qS.test(n)&&No(i,e.text,e.basePos)==No(i,n,e.basePos)},before:"SetextHeading"}]},Ep=class{nextLine(){return!1}finish(e,t){return e.addLeafElement(t,e.elt("Task",t.start,t.start+t.content.length,[e.elt("TaskMarker",t.start,t.start+3),...e.parser.parseInline(t.content.slice(3),t.start+3)])),!0}},xA={defineNodes:[{name:"Task",block:!0,style:m.list},{name:"TaskMarker",style:m.atom}],parseBlock:[{name:"TaskList",leaf(i,e){return/^\[[ xX]\][ \t]/.test(e.content)&&i.parentType().name=="ListItem"?new Ep:null},after:"SetextHeading"}]},wS=/(www\.)|(https?:\/\/)|([\w.+-]{1,100}@)|(mailto:|xmpp:)/gy,kS=/[\w-]+(\.[\w-]+)+(:\d+)?(\/[^\s<]*)?/gy,wA=/[\w-]+\.[\w-]+($|[/:])/,QS=/[\w.+-]+@[\w-]+(\.[\w.-]+)+/gy,vS=/\/[a-zA-Z\d@.]+/gy;function $S(i,e,t,n){let r=0;for(let s=e;s-1)return-1;let n=e+t[0].length;for(;;){let r=i[n-1],s;if(/[?!.,:*_~]/.test(r)||r==")"&&$S(i,e,n,")")>$S(i,e,n,"("))n--;else if(r==";"&&(s=/&(?:#\d+|#x[a-f\d]+|\w+);$/.exec(i.slice(e,n))))n=e+s.index;else break}return n}function PS(i,e){QS.lastIndex=e;let t=QS.exec(i);if(!t)return-1;let n=t[0][t[0].length-1];return n=="_"||n=="-"?-1:e+t[0].length-(n=="."?1:0)}var QA={parseInline:[{name:"Autolink",parse(i,e,t){let n=t-i.offset;if(n&&/\w/.test(i.text[n-1]))return-1;wS.lastIndex=n;let r=wS.exec(i.text),s=-1;if(!r)return-1;if(r[1]||r[2]){if(s=kA(i.text,n+r[0].length),s>-1&&i.hasOpenLink){let o=/([^\[\]]|\[[^\]]*\])*/.exec(i.text.slice(n,s));s=n+o[0].length}}else r[3]?s=PS(i.text,n):(s=PS(i.text,n+r[0].length),s>-1&&r[0]=="xmpp:"&&(vS.lastIndex=s,r=vS.exec(i.text),r&&(s=r.index+r[0].length)));return s<0?-1:(i.addElement(i.elt("URL",t,s+i.offset)),s+i.offset)}}]},GS=[SA,xA,yA,QA];function FS(i,e,t){return(n,r,s)=>{if(r!=i||n.char(s+1)==i)return-1;let o=[n.elt(t,s,s+1)];for(let l=s+1;lt%3==0).concat(this.state)}]@${this.pos}${this.score?"!"+this.score:""}`}static start(e,t,n=0){let r=e.parser.context;return new i(e,[],t,n,n,0,[],0,r?new th(r,r.start):null,0,null)}get context(){return this.curContext?this.curContext.context:null}pushState(e,t){this.stack.push(this.state,t,this.bufferBase+this.buffer.length),this.state=e}reduce(e){var t;let n=e>>19,r=e&65535,{parser:s}=this.p,o=this.reducePos=2e3&&!(!((t=this.p.parser.nodeSet.types[r])===null||t===void 0)&&t.isAnonymous)&&(c==this.p.lastBigReductionStart?(this.p.bigReductionCount++,this.p.lastBigReductionSize=h):this.p.lastBigReductionSizea;)this.stack.pop();this.reduceContext(r,c)}storeNode(e,t,n,r=4,s=!1){if(e==0&&(!this.stack.length||this.stack[this.stack.length-1]0&&this.buffer[o-4]==0&&this.buffer[o-1]>-1){if(t==n)return;if(this.buffer[o-2]>=t){this.buffer[o-2]=n;return}}}if(!s||this.pos==n)this.buffer.push(e,t,n,r);else{let o=this.buffer.length;if(o>0&&(this.buffer[o-4]!=0||this.buffer[o-1]<0)){let l=!1;for(let a=o;a>0&&this.buffer[a-2]>n;a-=4)if(this.buffer[a-1]>=0){l=!0;break}if(l)for(;o>0&&this.buffer[o-2]>n;)this.buffer[o]=this.buffer[o-4],this.buffer[o+1]=this.buffer[o-3],this.buffer[o+2]=this.buffer[o-2],this.buffer[o+3]=this.buffer[o-1],o-=4,r>4&&(r-=4)}this.buffer[o]=e,this.buffer[o+1]=t,this.buffer[o+2]=n,this.buffer[o+3]=r}}shift(e,t,n,r){if(e&131072)this.pushState(e&65535,this.pos);else if((e&262144)==0){let s=e,{parser:o}=this.p;this.pos=r;let l=o.stateFlag(s,1);!l&&(r>n||t<=o.maxNode)&&(this.reducePos=r),this.pushState(s,l?n:Math.min(n,this.reducePos)),this.shiftContext(t,n),t<=o.maxNode&&this.buffer.push(t,n,r,4)}else this.pos=r,this.shiftContext(t,n),t<=this.p.parser.maxNode&&this.buffer.push(t,n,r,4)}apply(e,t,n,r){e&65536?this.reduce(e):this.shift(e,t,n,r)}useNode(e,t){let n=this.p.reused.length-1;(n<0||this.p.reused[n]!=e)&&(this.p.reused.push(e),n++);let r=this.pos;this.reducePos=this.pos=r+e.length,this.pushState(t,r),this.buffer.push(n,r,this.reducePos,-1),this.curContext&&this.updateContext(this.curContext.tracker.reuse(this.curContext.context,e,this,this.p.stream.reset(this.pos-e.length)))}split(){let e=this,t=e.buffer.length;for(t&&e.buffer[t-4]==0&&(t-=4);t>0&&e.buffer[t-2]>e.reducePos;)t-=4;let n=e.buffer.slice(t),r=e.bufferBase+t;for(;e&&r==e.bufferBase;)e=e.parent;return new i(this.p,this.stack.slice(),this.state,this.reducePos,this.pos,this.score,n,r,this.curContext,this.lookAhead,e)}recoverByDelete(e,t){let n=e<=this.p.parser.maxNode;n&&this.storeNode(e,this.pos,t,4),this.storeNode(0,this.pos,t,n?8:4),this.pos=this.reducePos=t,this.score-=190}canShift(e){for(let t=new Up(this);;){let n=this.p.parser.stateSlot(t.state,4)||this.p.parser.hasAction(t.state,e);if(n==0)return!1;if((n&65536)==0)return!0;t.reduce(n)}}recoverByInsert(e){if(this.stack.length>=300)return[];let t=this.p.parser.nextStates(this.state);if(t.length>8||this.stack.length>=120){let r=[];for(let s=0,o;sa&1&&l==o)||r.push(t[s],o)}t=r}let n=[];for(let r=0;r>19,r=t&65535,s=this.stack.length-n*3;if(s<0||e.getGoto(this.stack[s],r,!1)<0){let o=this.findForcedReduction();if(o==null)return!1;t=o}this.storeNode(0,this.pos,this.pos,4,!0),this.score-=100}return this.reducePos=this.pos,this.reduce(t),!0}findForcedReduction(){let{parser:e}=this.p,t=[],n=(r,s)=>{if(!t.includes(r))return t.push(r),e.allActions(r,o=>{if(!(o&393216))if(o&65536){let l=(o>>19)-s;if(l>1){let a=o&65535,c=this.stack.length-l*3;if(c>=0&&e.getGoto(this.stack[c],a,!1)>=0)return l<<19|65536|a}}else{let l=n(o,s+1);if(l!=null)return l}})};return n(this.state,0)}forceAll(){for(;!this.p.parser.stateFlag(this.state,2);)if(!this.forceReduce()){this.storeNode(0,this.pos,this.pos,4,!0);break}return this}get deadEnd(){if(this.stack.length!=3)return!1;let{parser:e}=this.p;return e.data[e.stateSlot(this.state,1)]==65535&&!e.stateSlot(this.state,4)}restart(){this.storeNode(0,this.pos,this.pos,4,!0),this.state=this.stack[0],this.stack.length=0}sameState(e){if(this.state!=e.state||this.stack.length!=e.stack.length)return!1;for(let t=0;t0&&this.emitLookAhead()}},th=class{constructor(e,t){this.tracker=e,this.context=t,this.hash=e.strict?e.hash(t):0}},Up=class{constructor(e){this.start=e,this.state=e.state,this.stack=e.stack,this.base=this.stack.length}reduce(e){let t=e&65535,n=e>>19;n==0?(this.stack==this.start.stack&&(this.stack=this.stack.slice()),this.stack.push(this.state,0,0),this.base+=3):this.base-=(n-1)*3;let r=this.start.p.parser.getGoto(this.stack[this.base-3],t,!0);this.state=r}},Bp=class i{constructor(e,t,n){this.stack=e,this.pos=t,this.index=n,this.buffer=e.buffer,this.index==0&&this.maybeNext()}static create(e,t=e.bufferBase+e.buffer.length){return new i(e,t,t-e.bufferBase)}maybeNext(){let e=this.stack.parent;e!=null&&(this.index=this.stack.bufferBase-e.bufferBase,this.stack=e,this.buffer=e.buffer)}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}next(){this.index-=4,this.pos-=4,this.index==0&&this.maybeNext()}fork(){return new i(this.stack,this.pos,this.index)}};function Ho(i,e=Uint16Array){if(typeof i!="string")return i;let t=null;for(let n=0,r=0;n=92&&o--,o>=34&&o--;let a=o-32;if(a>=46&&(a-=46,l=!0),s+=a,l)break;s*=46}t?t[r++]=s:t=new e(s)}return t}var ps=class{constructor(){this.start=-1,this.value=-1,this.end=-1,this.extended=-1,this.lookAhead=0,this.mask=0,this.context=0}},ex=new ps,Vp=class{constructor(e,t){this.input=e,this.ranges=t,this.chunk="",this.chunkOff=0,this.chunk2="",this.chunk2Pos=0,this.next=-1,this.token=ex,this.rangeIndex=0,this.pos=this.chunkPos=t[0].from,this.range=t[0],this.end=t[t.length-1].to,this.readNext()}resolveOffset(e,t){let n=this.range,r=this.rangeIndex,s=this.pos+e;for(;sn.to:s>=n.to;){if(r==this.ranges.length-1)return null;let o=this.ranges[++r];s+=o.from-n.to,n=o}return s}clipPos(e){if(e>=this.range.from&&ee)return Math.max(e,t.from);return this.end}peek(e){let t=this.chunkOff+e,n,r;if(t>=0&&t=this.chunk2Pos&&nl.to&&(this.chunk2=this.chunk2.slice(0,l.to-n)),r=this.chunk2.charCodeAt(0)}}return n>=this.token.lookAhead&&(this.token.lookAhead=n+1),r}acceptToken(e,t=0){let n=t?this.resolveOffset(t,-1):this.pos;if(n==null||n=this.chunk2Pos&&this.posthis.range.to?e.slice(0,this.range.to-this.pos):e,this.chunkPos=this.pos,this.chunkOff=0}}readNext(){return this.chunkOff>=this.chunk.length&&(this.getChunk(),this.chunkOff==this.chunk.length)?this.next=-1:this.next=this.chunk.charCodeAt(this.chunkOff)}advance(e=1){for(this.chunkOff+=e;this.pos+e>=this.range.to;){if(this.rangeIndex==this.ranges.length-1)return this.setDone();e-=this.range.to-this.pos,this.range=this.ranges[++this.rangeIndex],this.pos=this.range.from}return this.pos+=e,this.pos>=this.token.lookAhead&&(this.token.lookAhead=this.pos+1),this.readNext()}setDone(){return this.pos=this.chunkPos=this.end,this.range=this.ranges[this.rangeIndex=this.ranges.length-1],this.chunk="",this.next=-1}reset(e,t){if(t?(this.token=t,t.start=e,t.lookAhead=e+1,t.value=t.extended=-1):this.token=ex,this.pos!=e){if(this.pos=e,e==this.end)return this.setDone(),this;for(;e=this.range.to;)this.range=this.ranges[++this.rangeIndex];e>=this.chunkPos&&e=this.chunkPos&&t<=this.chunkPos+this.chunk.length)return this.chunk.slice(e-this.chunkPos,t-this.chunkPos);if(e>=this.chunk2Pos&&t<=this.chunk2Pos+this.chunk2.length)return this.chunk2.slice(e-this.chunk2Pos,t-this.chunk2Pos);if(e>=this.range.from&&t<=this.range.to)return this.input.read(e,t);let n="";for(let r of this.ranges){if(r.from>=t)break;r.to>e&&(n+=this.input.read(Math.max(r.from,e),Math.min(r.to,t)))}return n}},on=class{constructor(e,t){this.data=e,this.id=t}token(e,t){let{parser:n}=t.p;sx(this.data,e,t,this.id,n.data,n.tokenPrecTable)}};on.prototype.contextual=on.prototype.fallback=on.prototype.extend=!1;var ln=class{constructor(e,t,n){this.precTable=t,this.elseToken=n,this.data=typeof e=="string"?Ho(e):e}token(e,t){let n=e.pos,r=0;for(;;){let s=e.next<0,o=e.resolveOffset(1,1);if(sx(this.data,e,t,0,this.data,this.precTable),e.token.value>-1)break;if(this.elseToken==null)return;if(s||r++,o==null)break;e.reset(o,e.token)}r&&(e.reset(n,e.token),e.acceptToken(this.elseToken,r))}};ln.prototype.contextual=on.prototype.fallback=on.prototype.extend=!1;var Me=class{constructor(e,t={}){this.token=e,this.contextual=!!t.contextual,this.fallback=!!t.fallback,this.extend=!!t.extend}};function sx(i,e,t,n,r,s){let o=0,l=1<0){let p=i[d];if(a.allows(p)&&(e.token.value==-1||e.token.value==p||$A(p,e.token.value,r,s))){e.acceptToken(p);break}}let h=e.next,f=0,u=i[o+2];if(e.next<0&&u>f&&i[c+u*3-3]==65535){o=i[c+u*3-1];continue e}for(;f>1,p=c+d+(d<<1),O=i[p],g=i[p+1]||65536;if(h=g)f=d+1;else{o=i[p+2],e.advance();continue e}}break}}function tx(i,e,t){for(let n=e,r;(r=i[n])!=65535;n++)if(r==t)return n-e;return-1}function $A(i,e,t,n){let r=tx(t,n,e);return r<0||tx(t,n,i)e)&&!n.type.isError)return t<0?Math.max(0,Math.min(n.to-1,e-25)):Math.min(i.length,Math.max(n.from+1,e+25));if(t<0?n.prevSibling():n.nextSibling())break;if(!n.parent())return t<0?0:i.length}}var Yp=class{constructor(e,t){this.fragments=e,this.nodeSet=t,this.i=0,this.fragment=null,this.safeFrom=-1,this.safeTo=-1,this.trees=[],this.start=[],this.index=[],this.nextFragment()}nextFragment(){let e=this.fragment=this.i==this.fragments.length?null:this.fragments[this.i++];if(e){for(this.safeFrom=e.openStart?ix(e.tree,e.from+e.offset,1)-e.offset:e.from,this.safeTo=e.openEnd?ix(e.tree,e.to+e.offset,-1)-e.offset:e.to;this.trees.length;)this.trees.pop(),this.start.pop(),this.index.pop();this.trees.push(e.tree),this.start.push(-e.offset),this.index.push(0),this.nextStart=this.safeFrom}else this.nextStart=1e9}nodeAt(e){if(ee)return this.nextStart=o,null;if(s instanceof J){if(o==e){if(o=Math.max(this.safeFrom,e)&&(this.trees.push(s),this.start.push(o),this.index.push(0))}else this.index[t]++,this.nextStart=o+s.length}}},zp=class{constructor(e,t){this.stream=t,this.tokens=[],this.mainToken=null,this.actions=[],this.tokens=e.tokenizers.map(n=>new ps)}getActions(e){let t=0,n=null,{parser:r}=e.p,{tokenizers:s}=r,o=r.stateSlot(e.state,3),l=e.curContext?e.curContext.hash:0,a=0;for(let c=0;cf.end+25&&(a=Math.max(f.lookAhead,a)),f.value!=0)){let u=t;if(f.extended>-1&&(t=this.addActions(e,f.extended,f.end,t)),t=this.addActions(e,f.value,f.end,t),!h.extend&&(n=f,t>u))break}}for(;this.actions.length>t;)this.actions.pop();return a&&e.setLookAhead(a),!n&&e.pos==this.stream.end&&(n=new ps,n.value=e.p.parser.eofTerm,n.start=n.end=e.pos,t=this.addActions(e,n.value,n.end,t)),this.mainToken=n,this.actions}getMainToken(e){if(this.mainToken)return this.mainToken;let t=new ps,{pos:n,p:r}=e;return t.start=n,t.end=Math.min(n+1,r.stream.end),t.value=n==r.stream.end?r.parser.eofTerm:0,t}updateCachedToken(e,t,n){let r=this.stream.clipPos(n.pos);if(t.token(this.stream.reset(r,e),n),e.value>-1){let{parser:s}=n.p;for(let o=0;o=0&&n.p.parser.dialect.allows(l>>1)){(l&1)==0?e.value=l>>1:e.extended=l>>1;break}}}else e.value=0,e.end=this.stream.clipPos(r+1)}putAction(e,t,n,r){for(let s=0;se.bufferLength*4?new Yp(n,e.nodeSet):null}get parsedPos(){return this.minStackPos}advance(){let e=this.stacks,t=this.minStackPos,n=this.stacks=[],r,s;if(this.bigReductionCount>300&&e.length==1){let[o]=e;for(;o.forceReduce()&&o.stack.length&&o.stack[o.stack.length-2]>=this.lastBigReductionStart;);this.bigReductionCount=this.lastBigReductionSize=0}for(let o=0;ot)n.push(l);else{if(this.advanceStack(l,n,e))continue;{r||(r=[],s=[]),r.push(l);let a=this.tokens.getMainToken(l);s.push(a.value,a.end)}}break}}if(!n.length){let o=r&&PA(r);if(o)return kt&&console.log("Finish with "+this.stackID(o)),this.stackToTree(o);if(this.parser.strict)throw kt&&r&&console.log("Stuck with token "+(this.tokens.mainToken?this.parser.getName(this.tokens.mainToken.value):"none")),new SyntaxError("No parse at "+t);this.recovering||(this.recovering=5)}if(this.recovering&&r){let o=this.stoppedAt!=null&&r[0].pos>this.stoppedAt?r[0]:this.runRecovery(r,s,n);if(o)return kt&&console.log("Force-finish "+this.stackID(o)),this.stackToTree(o.forceAll())}if(this.recovering){let o=this.recovering==1?1:this.recovering*3;if(n.length>o)for(n.sort((l,a)=>a.score-l.score);n.length>o;)n.pop();n.some(l=>l.reducePos>t)&&this.recovering--}else if(n.length>1){e:for(let o=0;o500&&c.buffer.length>500)if((l.score-c.score||l.buffer.length-c.buffer.length)>0)n.splice(a--,1);else{n.splice(o--,1);continue e}}}n.length>12&&(n.sort((o,l)=>l.score-o.score),n.splice(12,n.length-12))}this.minStackPos=n[0].pos;for(let o=1;o ":"";if(this.stoppedAt!=null&&r>this.stoppedAt)return e.forceReduce()?e:null;if(this.fragments){let c=e.curContext&&e.curContext.tracker.strict,h=c?e.curContext.hash:0;for(let f=this.fragments.nodeAt(r);f;){let u=this.parser.nodeSet.types[f.type.id]==f.type?s.getGoto(e.state,f.type.id):-1;if(u>-1&&f.length&&(!c||(f.prop(U.contextHash)||0)==h))return e.useNode(f,u),kt&&console.log(o+this.stackID(e)+` (via reuse of ${s.getName(f.type.id)})`),!0;if(!(f instanceof J)||f.children.length==0||f.positions[0]>0)break;let d=f.children[0];if(d instanceof J&&f.positions[0]==0)f=d;else break}}let l=s.stateSlot(e.state,4);if(l>0)return e.reduce(l),kt&&console.log(o+this.stackID(e)+` (via always-reduce ${s.getName(l&65535)})`),!0;if(e.stack.length>=8400)for(;e.stack.length>6e3&&e.forceReduce(););let a=this.tokens.getActions(e);for(let c=0;cr?t.push(p):n.push(p)}return!1}advanceFully(e,t){let n=e.pos;for(;;){if(!this.advanceStack(e,null,null))return!1;if(e.pos>n)return nx(e,t),!0}}runRecovery(e,t,n){let r=null,s=!1;for(let o=0;o ":"";if(l.deadEnd&&(s||(s=!0,l.restart(),kt&&console.log(h+this.stackID(l)+" (restarted)"),this.advanceFully(l,n))))continue;let f=l.split(),u=h;for(let d=0;d<10&&f.forceReduce()&&(kt&&console.log(u+this.stackID(f)+" (via force-reduce)"),!this.advanceFully(f,n));d++)kt&&(u=this.stackID(f)+" -> ");for(let d of l.recoverByInsert(a))kt&&console.log(h+this.stackID(d)+" (via recover-insert)"),this.advanceFully(d,n);this.stream.end>l.pos?(c==l.pos&&(c++,a=0),l.recoverByDelete(a,c),kt&&console.log(h+this.stackID(l)+` (via recover-delete ${this.parser.getName(a)})`),nx(l,n)):(!r||r.scorei,Os=class{constructor(e){this.start=e.start,this.shift=e.shift||jp,this.reduce=e.reduce||jp,this.reuse=e.reuse||jp,this.hash=e.hash||(()=>0),this.strict=e.strict!==!1}},an=class i extends Ni{constructor(e){if(super(),this.wrappers=[],e.version!=14)throw new RangeError(`Parser version (${e.version}) doesn't match runtime version (14)`);let t=e.nodeNames.split(" ");this.minRepeatTerm=t.length;for(let l=0;le.topRules[l][1]),r=[];for(let l=0;l=0)s(h,a,l[c++]);else{let f=l[c+-h];for(let u=-h;u>0;u--)s(l[c++],a,f);c++}}}this.nodeSet=new zi(t.map((l,a)=>Oe.define({name:a>=this.minRepeatTerm?void 0:l,id:a,props:r[a],top:n.indexOf(a)>-1,error:a==0,skipped:e.skippedNodes&&e.skippedNodes.indexOf(a)>-1}))),e.propSources&&(this.nodeSet=this.nodeSet.extend(...e.propSources)),this.strict=!1,this.bufferLength=1024;let o=Ho(e.tokenData);this.context=e.context,this.specializerSpecs=e.specialized||[],this.specialized=new Uint16Array(this.specializerSpecs.length);for(let l=0;ltypeof l=="number"?new on(o,l):l),this.topRules=e.topRules,this.dialects=e.dialects||{},this.dynamicPrecedences=e.dynamicPrecedences||null,this.tokenPrecTable=e.tokenPrec,this.termNames=e.termNames||null,this.maxNode=this.nodeSet.types.length-1,this.dialect=this.parseDialect(),this.top=this.topRules[Object.keys(this.topRules)[0]]}createParse(e,t,n){let r=new Wp(this,e,t,n);for(let s of this.wrappers)r=s(r,e,t,n);return r}getGoto(e,t,n=!1){let r=this.goto;if(t>=r[0])return-1;for(let s=r[t+1];;){let o=r[s++],l=o&1,a=r[s++];if(l&&n)return a;for(let c=s+(o>>1);s0}validAction(e,t){return!!this.allActions(e,n=>n==t?!0:null)}allActions(e,t){let n=this.stateSlot(e,4),r=n?t(n):void 0;for(let s=this.stateSlot(e,1);r==null;s+=3){if(this.data[s]==65535)if(this.data[s+1]==1)s=Ci(this.data,s+2);else break;r=t(Ci(this.data,s+1))}return r}nextStates(e){let t=[];for(let n=this.stateSlot(e,1);;n+=3){if(this.data[n]==65535)if(this.data[n+1]==1)n=Ci(this.data,n+2);else break;if((this.data[n+2]&1)==0){let r=this.data[n+1];t.some((s,o)=>o&1&&s==r)||t.push(this.data[n],r)}}return t}configure(e){let t=Object.assign(Object.create(i.prototype),this);if(e.props&&(t.nodeSet=this.nodeSet.extend(...e.props)),e.top){let n=this.topRules[e.top];if(!n)throw new RangeError(`Invalid top rule name ${e.top}`);t.top=n}return e.tokenizers&&(t.tokenizers=this.tokenizers.map(n=>{let r=e.tokenizers.find(s=>s.from==n);return r?r.to:n})),e.specializers&&(t.specializers=this.specializers.slice(),t.specializerSpecs=this.specializerSpecs.map((n,r)=>{let s=e.specializers.find(l=>l.from==n.external);if(!s)return n;let o=Object.assign(Object.assign({},n),{external:s.to});return t.specializers[r]=rx(o),o})),e.contextTracker&&(t.context=e.contextTracker),e.dialect&&(t.dialect=this.parseDialect(e.dialect)),e.strict!=null&&(t.strict=e.strict),e.wrap&&(t.wrappers=t.wrappers.concat(e.wrap)),e.bufferLength!=null&&(t.bufferLength=e.bufferLength),t}hasWrappers(){return this.wrappers.length>0}getName(e){return this.termNames?this.termNames[e]:String(e<=this.maxNode&&this.nodeSet.types[e].name||e)}get eofTerm(){return this.maxNode+1}get topNode(){return this.nodeSet.types[this.top[1]]}dynamicPrecedence(e){let t=this.dynamicPrecedences;return t==null?0:t[e]||0}parseDialect(e){let t=Object.keys(this.dialects),n=t.map(()=>!1);if(e)for(let s of e.split(" ")){let o=t.indexOf(s);o>=0&&(n[o]=!0)}let r=null;for(let s=0;sn)&&t.p.parser.stateFlag(t.state,2)&&(!e||e.scorei.external(t,n)<<1|e}return i.get}var CA=55,TA=1,AA=56,MA=2,RA=57,XA=3,ox=4,EA=5,Kp=6,px=7,Ox=8,gx=9,mx=10,ZA=11,_A=12,LA=13,qp=58,DA=14,jA=15,lx=59,bx=21,IA=23,yx=24,UA=25,Fp=27,Sx=28,BA=29,VA=32,YA=35,zA=37,WA=38,NA=0,qA=1,GA={area:!0,base:!0,br:!0,col:!0,command:!0,embed:!0,frame:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0,menuitem:!0},FA={dd:!0,li:!0,optgroup:!0,option:!0,p:!0,rp:!0,rt:!0,tbody:!0,td:!0,tfoot:!0,th:!0,tr:!0},ax={dd:{dd:!0,dt:!0},dt:{dd:!0,dt:!0},li:{li:!0},option:{option:!0,optgroup:!0},optgroup:{optgroup:!0},p:{address:!0,article:!0,aside:!0,blockquote:!0,dir:!0,div:!0,dl:!0,fieldset:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,menu:!0,nav:!0,ol:!0,p:!0,pre:!0,section:!0,table:!0,ul:!0},rp:{rp:!0,rt:!0},rt:{rp:!0,rt:!0},tbody:{tbody:!0,tfoot:!0},td:{td:!0,th:!0},tfoot:{tbody:!0},th:{td:!0,th:!0},thead:{tbody:!0,tfoot:!0},tr:{tr:!0}};function HA(i){return i==45||i==46||i==58||i>=65&&i<=90||i==95||i>=97&&i<=122||i>=161}var cx=null,hx=null,fx=0;function Hp(i,e){let t=i.pos+e;if(fx==t&&hx==i)return cx;let n=i.peek(e),r="";for(;HA(n);)r+=String.fromCharCode(n),n=i.peek(++e);return hx=i,fx=t,cx=r?r.toLowerCase():n==KA||n==JA?void 0:null}var xx=60,ih=62,Jp=47,KA=63,JA=33,eM=45;function ux(i,e){this.name=i,this.parent=e}var tM=[Kp,mx,px,Ox,gx],iM=new Os({start:null,shift(i,e,t,n){return tM.indexOf(e)>-1?new ux(Hp(n,1)||"",i):i},reduce(i,e){return e==bx&&i?i.parent:i},reuse(i,e,t,n){let r=e.type.id;return r==Kp||r==zA?new ux(Hp(n,1)||"",i):i},strict:!1}),nM=new Me((i,e)=>{if(i.next!=xx){i.next<0&&e.context&&i.acceptToken(qp);return}i.advance();let t=i.next==Jp;t&&i.advance();let n=Hp(i,0);if(n===void 0)return;if(!n)return i.acceptToken(t?jA:DA);let r=e.context?e.context.name:null;if(t){if(n==r)return i.acceptToken(ZA);if(r&&FA[r])return i.acceptToken(qp,-2);if(e.dialectEnabled(NA))return i.acceptToken(_A);for(let s=e.context;s;s=s.parent)if(s.name==n)return;i.acceptToken(LA)}else{if(n=="script")return i.acceptToken(px);if(n=="style")return i.acceptToken(Ox);if(n=="textarea")return i.acceptToken(gx);if(GA.hasOwnProperty(n))return i.acceptToken(mx);r&&ax[r]&&ax[r][n]?i.acceptToken(qp,-1):i.acceptToken(Kp)}},{contextual:!0}),rM=new Me(i=>{for(let e=0,t=0;;t++){if(i.next<0){t&&i.acceptToken(lx);break}if(i.next==eM)e++;else if(i.next==ih&&e>=2){t>=3&&i.acceptToken(lx,-2);break}else e=0;i.advance()}});function sM(i){for(;i;i=i.parent)if(i.name=="svg"||i.name=="math")return!0;return!1}var oM=new Me((i,e)=>{if(i.next==Jp&&i.peek(1)==ih){let t=e.dialectEnabled(qA)||sM(e.context);i.acceptToken(t?EA:ox,2)}else i.next==ih&&i.acceptToken(ox,1)});function eO(i,e,t){let n=2+i.length;return new Me(r=>{for(let s=0,o=0,l=0;;l++){if(r.next<0){l&&r.acceptToken(e);break}if(s==0&&r.next==xx||s==1&&r.next==Jp||s>=2&&so?r.acceptToken(e,-o):r.acceptToken(t,-(o-2));break}else if((r.next==10||r.next==13)&&l){r.acceptToken(e,1);break}else s=o=0;r.advance()}})}var lM=eO("script",CA,TA),aM=eO("style",AA,MA),cM=eO("textarea",RA,XA),hM=Bt({"Text RawText IncompleteTag IncompleteCloseTag":m.content,"StartTag StartCloseTag SelfClosingEndTag EndTag":m.angleBracket,TagName:m.tagName,"MismatchedCloseTag/TagName":[m.tagName,m.invalid],AttributeName:m.attributeName,"AttributeValue UnquotedAttributeValue":m.attributeValue,Is:m.definitionOperator,"EntityReference CharacterReference":m.character,Comment:m.blockComment,ProcessingInst:m.processingInstruction,DoctypeDecl:m.documentMeta}),wx=an.deserialize({version:14,states:",xOVO!rOOO!ZQ#tO'#CrO!`Q#tO'#C{O!eQ#tO'#DOO!jQ#tO'#DRO!oQ#tO'#DTO!tOaO'#CqO#PObO'#CqO#[OdO'#CqO$kO!rO'#CqOOO`'#Cq'#CqO$rO$fO'#DUO$zQ#tO'#DWO%PQ#tO'#DXOOO`'#Dl'#DlOOO`'#DZ'#DZQVO!rOOO%UQ&rO,59^O%aQ&rO,59gO%lQ&rO,59jO%wQ&rO,59mO&SQ&rO,59oOOOa'#D_'#D_O&_OaO'#CyO&jOaO,59]OOOb'#D`'#D`O&rObO'#C|O&}ObO,59]OOOd'#Da'#DaO'VOdO'#DPO'bOdO,59]OOO`'#Db'#DbO'jO!rO,59]O'qQ#tO'#DSOOO`,59],59]OOOp'#Dc'#DcO'vO$fO,59pOOO`,59p,59pO(OQ#|O,59rO(TQ#|O,59sOOO`-E7X-E7XO(YQ&rO'#CtOOQW'#D['#D[O(hQ&rO1G.xOOOa1G.x1G.xOOO`1G/Z1G/ZO(sQ&rO1G/ROOOb1G/R1G/RO)OQ&rO1G/UOOOd1G/U1G/UO)ZQ&rO1G/XOOO`1G/X1G/XO)fQ&rO1G/ZOOOa-E7]-E7]O)qQ#tO'#CzOOO`1G.w1G.wOOOb-E7^-E7^O)vQ#tO'#C}OOOd-E7_-E7_O){Q#tO'#DQOOO`-E7`-E7`O*QQ#|O,59nOOOp-E7a-E7aOOO`1G/[1G/[OOO`1G/^1G/^OOO`1G/_1G/_O*VQ,UO,59`OOQW-E7Y-E7YOOOa7+$d7+$dOOO`7+$u7+$uOOOb7+$m7+$mOOOd7+$p7+$pOOO`7+$s7+$sO*bQ#|O,59fO*gQ#|O,59iO*lQ#|O,59lOOO`1G/Y1G/YO*qO7[O'#CwO+SOMhO'#CwOOQW1G.z1G.zOOO`1G/Q1G/QOOO`1G/T1G/TOOO`1G/W1G/WOOOO'#D]'#D]O+eO7[O,59cOOQW,59c,59cOOOO'#D^'#D^O+vOMhO,59cOOOO-E7Z-E7ZOOQW1G.}1G.}OOOO-E7[-E7[",stateData:",c~O!_OS~OUSOVPOWQOXROYTO[]O][O^^O_^Oa^Ob^Oc^Od^Oy^O|_O!eZO~OgaO~OgbO~OgcO~OgdO~OgeO~O!XfOPmP![mP~O!YiOQpP![pP~O!ZlORsP![sP~OUSOVPOWQOXROYTOZqO[]O][O^^O_^Oa^Ob^Oc^Od^Oy^O!eZO~O![rO~P#gO!]sO!fuO~OgvO~OgwO~OS|OT}OiyO~OS!POT}OiyO~OS!ROT}OiyO~OS!TOT}OiyO~OS}OT}OiyO~O!XfOPmX![mX~OP!WO![!XO~O!YiOQpX![pX~OQ!ZO![!XO~O!ZlORsX![sX~OR!]O![!XO~O![!XO~P#gOg!_O~O!]sO!f!aO~OS!bO~OS!cO~Oj!dOShXThXihX~OS!fOT!gOiyO~OS!hOT!gOiyO~OS!iOT!gOiyO~OS!jOT!gOiyO~OS!gOT!gOiyO~Og!kO~Og!lO~Og!mO~OS!nO~Ol!qO!a!oO!c!pO~OS!rO~OS!sO~OS!tO~Ob!uOc!uOd!uO!a!wO!b!uO~Ob!xOc!xOd!xO!c!wO!d!xO~Ob!uOc!uOd!uO!a!{O!b!uO~Ob!xOc!xOd!xO!c!{O!d!xO~OT~cbd!ey|!e~",goto:"%q!aPPPPPPPPPPPPPPPPPPPPP!b!hP!nPP!zP!}#Q#T#Z#^#a#g#j#m#s#y!bP!b!bP$P$V$m$s$y%P%V%]%cPPPPPPPP%iX^OX`pXUOX`pezabcde{!O!Q!S!UR!q!dRhUR!XhXVOX`pRkVR!XkXWOX`pRnWR!XnXXOX`pQrXR!XpXYOX`pQ`ORx`Q{aQ!ObQ!QcQ!SdQ!UeZ!e{!O!Q!S!UQ!v!oR!z!vQ!y!pR!|!yQgUR!VgQjVR!YjQmWR![mQpXR!^pQtZR!`tS_O`ToXp",nodeNames:"\u26A0 StartCloseTag StartCloseTag StartCloseTag EndTag SelfClosingEndTag StartTag StartTag StartTag StartTag StartTag StartCloseTag StartCloseTag StartCloseTag IncompleteTag IncompleteCloseTag Document Text EntityReference CharacterReference InvalidEntity Element OpenTag TagName Attribute AttributeName Is AttributeValue UnquotedAttributeValue ScriptText CloseTag OpenTag StyleText CloseTag OpenTag TextareaText CloseTag OpenTag CloseTag SelfClosingTag Comment ProcessingInst MismatchedCloseTag CloseTag DoctypeDecl",maxTerm:68,context:iM,nodeProps:[["closedBy",-10,1,2,3,7,8,9,10,11,12,13,"EndTag",6,"EndTag SelfClosingEndTag",-4,22,31,34,37,"CloseTag"],["openedBy",4,"StartTag StartCloseTag",5,"StartTag",-4,30,33,36,38,"OpenTag"],["group",-10,14,15,18,19,20,21,40,41,42,43,"Entity",17,"Entity TextContent",-3,29,32,35,"TextContent Entity"],["isolate",-11,22,30,31,33,34,36,37,38,39,42,43,"ltr",-3,27,28,40,""]],propSources:[hM],skippedNodes:[0],repeatNodeCount:9,tokenData:"!]tw8twx7Sx!P8t!P!Q5u!Q!]8t!]!^/^!^!a7S!a#S8t#S#T;{#T#s8t#s$f5u$f;'S8t;'S;=`>V<%l?Ah8t?Ah?BY5u?BY?Mn8t?MnO5u!Z5zblWOX5uXZ7SZ[5u[^7S^p5uqr5urs7Sst+Ptw5uwx7Sx!]5u!]!^7w!^!a7S!a#S5u#S#T7S#T;'S5u;'S;=`8n<%lO5u!R7VVOp7Sqs7St!]7S!]!^7l!^;'S7S;'S;=`7q<%lO7S!R7qOb!R!R7tP;=`<%l7S!Z8OYlWb!ROX+PZ[+P^p+Pqr+Psw+Px!^+P!a#S+P#T;'S+P;'S;=`+t<%lO+P!Z8qP;=`<%l5u!_8{iiSlWOX5uXZ7SZ[5u[^7S^p5uqr8trs7Sst/^tw8twx7Sx!P8t!P!Q5u!Q!]8t!]!^:j!^!a7S!a#S8t#S#T;{#T#s8t#s$f5u$f;'S8t;'S;=`>V<%l?Ah8t?Ah?BY5u?BY?Mn8t?MnO5u!_:sbiSlWb!ROX+PZ[+P^p+Pqr/^sw/^x!P/^!P!Q+P!Q!^/^!a#S/^#S#T0m#T#s/^#s$f+P$f;'S/^;'S;=`1e<%l?Ah/^?Ah?BY+P?BY?Mn/^?MnO+P!VP<%l?Ah;{?Ah?BY7S?BY?Mn;{?MnO7S!V=dXiSb!Rqr0msw0mx!P0m!Q!^0m!a#s0m$f;'S0m;'S;=`1_<%l?Ah0m?BY?Mn0m!V>SP;=`<%l;{!_>YP;=`<%l8t!_>dhiSlWOX@OXZAYZ[@O[^AY^p@OqrBwrsAYswBwwxAYx!PBw!P!Q@O!Q!]Bw!]!^/^!^!aAY!a#SBw#S#TE{#T#sBw#s$f@O$f;'SBw;'S;=`HS<%l?AhBw?Ah?BY@O?BY?MnBw?MnO@O!Z@TalWOX@OXZAYZ[@O[^AY^p@Oqr@OrsAYsw@OwxAYx!]@O!]!^Az!^!aAY!a#S@O#S#TAY#T;'S@O;'S;=`Bq<%lO@O!RA]UOpAYq!]AY!]!^Ao!^;'SAY;'S;=`At<%lOAY!RAtOc!R!RAwP;=`<%lAY!ZBRYlWc!ROX+PZ[+P^p+Pqr+Psw+Px!^+P!a#S+P#T;'S+P;'S;=`+t<%lO+P!ZBtP;=`<%l@O!_COhiSlWOX@OXZAYZ[@O[^AY^p@OqrBwrsAYswBwwxAYx!PBw!P!Q@O!Q!]Bw!]!^Dj!^!aAY!a#SBw#S#TE{#T#sBw#s$f@O$f;'SBw;'S;=`HS<%l?AhBw?Ah?BY@O?BY?MnBw?MnO@O!_DsbiSlWc!ROX+PZ[+P^p+Pqr/^sw/^x!P/^!P!Q+P!Q!^/^!a#S/^#S#T0m#T#s/^#s$f+P$f;'S/^;'S;=`1e<%l?Ah/^?Ah?BY+P?BY?Mn/^?MnO+P!VFQbiSOpAYqrE{rsAYswE{wxAYx!PE{!P!QAY!Q!]E{!]!^GY!^!aAY!a#sE{#s$fAY$f;'SE{;'S;=`G|<%l?AhE{?Ah?BYAY?BY?MnE{?MnOAY!VGaXiSc!Rqr0msw0mx!P0m!Q!^0m!a#s0m$f;'S0m;'S;=`1_<%l?Ah0m?BY?Mn0m!VHPP;=`<%lE{!_HVP;=`<%lBw!ZHcW!cxaP!b`Or(trs'ksv(tw!^(t!^!_)e!_;'S(t;'S;=`*P<%lO(t!aIYliSaPlW!b`!dpOX$qXZ&XZ[$q[^&X^p$qpq&Xqr-_rs&}sv-_vw/^wx(tx}-_}!OKQ!O!P-_!P!Q$q!Q!^-_!^!_*V!_!a&X!a#S-_#S#T1k#T#s-_#s$f$q$f;'S-_;'S;=`3X<%l?Ah-_?Ah?BY$q?BY?Mn-_?MnO$q!aK_kiSaPlW!b`!dpOX$qXZ&XZ[$q[^&X^p$qpq&Xqr-_rs&}sv-_vw/^wx(tx!P-_!P!Q$q!Q!^-_!^!_*V!_!`&X!`!aMS!a#S-_#S#T1k#T#s-_#s$f$q$f;'S-_;'S;=`3X<%l?Ah-_?Ah?BY$q?BY?Mn-_?MnO$q!TM_XaP!b`!dp!fQOr&Xrs&}sv&Xwx(tx!^&X!^!_*V!_;'S&X;'S;=`*y<%lO&X!aNZ!ZiSgQaPlW!b`!dpOX$qXZ&XZ[$q[^&X^p$qpq&Xqr-_rs&}sv-_vw/^wx(tx}-_}!OMz!O!PMz!P!Q$q!Q![Mz![!]Mz!]!^-_!^!_*V!_!a&X!a!c-_!c!}Mz!}#R-_#R#SMz#S#T1k#T#oMz#o#s-_#s$f$q$f$}-_$}%OMz%O%W-_%W%oMz%o%p-_%p&aMz&a&b-_&b1pMz1p4UMz4U4dMz4d4e-_4e$ISMz$IS$I`-_$I`$IbMz$Ib$Je-_$Je$JgMz$Jg$Kh-_$Kh%#tMz%#t&/x-_&/x&EtMz&Et&FV-_&FV;'SMz;'S;:j!#|;:j;=`3X<%l?&r-_?&r?AhMz?Ah?BY$q?BY?MnMz?MnO$q!a!$PP;=`<%lMz!R!$ZY!b`!dpOq*Vqr!$yrs(Vsv*Vwx)ex!a*V!a!b!4t!b;'S*V;'S;=`*s<%lO*V!R!%Q]!b`!dpOr*Vrs(Vsv*Vwx)ex}*V}!O!%y!O!f*V!f!g!']!g#W*V#W#X!0`#X;'S*V;'S;=`*s<%lO*V!R!&QX!b`!dpOr*Vrs(Vsv*Vwx)ex}*V}!O!&m!O;'S*V;'S;=`*s<%lO*V!R!&vV!b`!dp!ePOr*Vrs(Vsv*Vwx)ex;'S*V;'S;=`*s<%lO*V!R!'dX!b`!dpOr*Vrs(Vsv*Vwx)ex!q*V!q!r!(P!r;'S*V;'S;=`*s<%lO*V!R!(WX!b`!dpOr*Vrs(Vsv*Vwx)ex!e*V!e!f!(s!f;'S*V;'S;=`*s<%lO*V!R!(zX!b`!dpOr*Vrs(Vsv*Vwx)ex!v*V!v!w!)g!w;'S*V;'S;=`*s<%lO*V!R!)nX!b`!dpOr*Vrs(Vsv*Vwx)ex!{*V!{!|!*Z!|;'S*V;'S;=`*s<%lO*V!R!*bX!b`!dpOr*Vrs(Vsv*Vwx)ex!r*V!r!s!*}!s;'S*V;'S;=`*s<%lO*V!R!+UX!b`!dpOr*Vrs(Vsv*Vwx)ex!g*V!g!h!+q!h;'S*V;'S;=`*s<%lO*V!R!+xY!b`!dpOr!+qrs!,hsv!+qvw!-Swx!.[x!`!+q!`!a!/j!a;'S!+q;'S;=`!0Y<%lO!+qq!,mV!dpOv!,hvx!-Sx!`!,h!`!a!-q!a;'S!,h;'S;=`!.U<%lO!,hP!-VTO!`!-S!`!a!-f!a;'S!-S;'S;=`!-k<%lO!-SP!-kO|PP!-nP;=`<%l!-Sq!-xS!dp|POv(Vx;'S(V;'S;=`(h<%lO(Vq!.XP;=`<%l!,ha!.aX!b`Or!.[rs!-Ssv!.[vw!-Sw!`!.[!`!a!.|!a;'S!.[;'S;=`!/d<%lO!.[a!/TT!b`|POr)esv)ew;'S)e;'S;=`)y<%lO)ea!/gP;=`<%l!.[!R!/sV!b`!dp|POr*Vrs(Vsv*Vwx)ex;'S*V;'S;=`*s<%lO*V!R!0]P;=`<%l!+q!R!0gX!b`!dpOr*Vrs(Vsv*Vwx)ex#c*V#c#d!1S#d;'S*V;'S;=`*s<%lO*V!R!1ZX!b`!dpOr*Vrs(Vsv*Vwx)ex#V*V#V#W!1v#W;'S*V;'S;=`*s<%lO*V!R!1}X!b`!dpOr*Vrs(Vsv*Vwx)ex#h*V#h#i!2j#i;'S*V;'S;=`*s<%lO*V!R!2qX!b`!dpOr*Vrs(Vsv*Vwx)ex#m*V#m#n!3^#n;'S*V;'S;=`*s<%lO*V!R!3eX!b`!dpOr*Vrs(Vsv*Vwx)ex#d*V#d#e!4Q#e;'S*V;'S;=`*s<%lO*V!R!4XX!b`!dpOr*Vrs(Vsv*Vwx)ex#X*V#X#Y!+q#Y;'S*V;'S;=`*s<%lO*V!R!4{Y!b`!dpOr!4trs!5ksv!4tvw!6Vwx!8]x!a!4t!a!b!:]!b;'S!4t;'S;=`!;r<%lO!4tq!5pV!dpOv!5kvx!6Vx!a!5k!a!b!7W!b;'S!5k;'S;=`!8V<%lO!5kP!6YTO!a!6V!a!b!6i!b;'S!6V;'S;=`!7Q<%lO!6VP!6lTO!`!6V!`!a!6{!a;'S!6V;'S;=`!7Q<%lO!6VP!7QOyPP!7TP;=`<%l!6Vq!7]V!dpOv!5kvx!6Vx!`!5k!`!a!7r!a;'S!5k;'S;=`!8V<%lO!5kq!7yS!dpyPOv(Vx;'S(V;'S;=`(h<%lO(Vq!8YP;=`<%l!5ka!8bX!b`Or!8]rs!6Vsv!8]vw!6Vw!a!8]!a!b!8}!b;'S!8];'S;=`!:V<%lO!8]a!9SX!b`Or!8]rs!6Vsv!8]vw!6Vw!`!8]!`!a!9o!a;'S!8];'S;=`!:V<%lO!8]a!9vT!b`yPOr)esv)ew;'S)e;'S;=`)y<%lO)ea!:YP;=`<%l!8]!R!:dY!b`!dpOr!4trs!5ksv!4tvw!6Vwx!8]x!`!4t!`!a!;S!a;'S!4t;'S;=`!;r<%lO!4t!R!;]V!b`!dpyPOr*Vrs(Vsv*Vwx)ex;'S*V;'S;=`*s<%lO*V!R!;uP;=`<%l!4t!V!{let c=l.type.id;if(c==BA)return Gp(l,a,t);if(c==VA)return Gp(l,a,n);if(c==YA)return Gp(l,a,r);if(c==bx&&s.length){let h=l.node,f=h.firstChild,u=f&&dx(f,a),d;if(u){for(let p of s)if(p.tag==u&&(!p.attrs||p.attrs(d||(d=kx(f,a))))){let O=h.lastChild,g=O.type.id==WA?O.from:h.to;if(g>f.to)return{parser:p.parser,overlay:[{from:f.to,to:g}]}}}}if(o&&c==yx){let h=l.node,f;if(f=h.firstChild){let u=o[a.read(f.from,f.to)];if(u)for(let d of u){if(d.tagName&&d.tagName!=dx(h.parent,a))continue;let p=h.lastChild;if(p.type.id==Fp){let O=p.from+1,g=p.lastChild,b=p.to-(g&&g.isError?0:1);if(b>O)return{parser:d.parser,overlay:[{from:O,to:b}],bracketed:!0}}else if(p.type.id==Sx)return{parser:d.parser,overlay:[{from:p.from,to:p.to}]}}}}return null})}var fM=148,Qx=1,uM=149,dM=150,Px=2,pM=151,OM=3,gM=4,mM=152,Cx=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],bM=58,yM=40,Tx=95,SM=91,nh=45,xM=46,wM=35,kM=37,QM=38,vM=92,$M=10,PM=42;function Ko(i){return i>=65&&i<=90||i>=97&&i<=122||i>=161}function iO(i){return i>=48&&i<=57}function vx(i){return iO(i)||i>=97&&i<=102||i>=65&&i<=70}var Ax=(i,e,t)=>(n,r)=>{for(let s=!1,o=0,l=0;;l++){let{next:a}=n;if(Ko(a)||a==nh||a==Tx||s&&iO(a))!s&&(a!=nh||l>0)&&(s=!0),o===l&&a==nh&&o++,n.advance();else if(a==vM&&n.peek(1)!=$M){if(n.advance(),vx(n.next)){do n.advance();while(vx(n.next));n.next==32&&n.advance()}else n.next>-1&&n.advance();s=!0}else{s&&n.acceptToken(o>=2&&r.canShift(Px)?e:a==yM?t:i);break}}},CM=new Me(Ax(uM,Px,dM),{contextual:!0}),TM=new Me(Ax(pM,OM,gM),{contextual:!0}),AM=new Me(i=>{if(Cx.includes(i.peek(-1))){let{next:e}=i;(Ko(e)||e==Tx||e==wM||e==xM||e==PM||e==SM||e==bM&&Ko(i.peek(1))||e==nh||e==QM)&&i.acceptToken(fM)}}),MM=new Me(i=>{if(!Cx.includes(i.peek(-1))){let{next:e}=i;if(e==kM&&(i.advance(),i.acceptToken(Qx)),Ko(e)){do i.advance();while(Ko(i.next)||iO(i.next));i.acceptToken(Qx)}}});function $x(i){return/^#[a-f\d]{3}([a-f\d]{3}([a-f\d]{2})?)?$/i.test(i)?mM:-1}var RM=Bt({"AtKeyword import charset namespace keyframes media supports font-feature-values":m.definitionKeyword,"from to selector scope MatchFlag":m.keyword,NamespaceName:m.namespace,KeyframeName:m.labelName,KeyframeRangeName:m.operatorKeyword,TagName:m.tagName,ClassName:m.className,PseudoClassName:m.constant(m.className),IdName:m.labelName,"FeatureName PropertyName":m.propertyName,AttributeName:m.attributeName,NumberLiteral:m.number,KeywordQuery:m.keyword,UnaryQueryOp:m.operatorKeyword,"CallTag ValueName FontName":m.atom,VariableName:m.variableName,Callee:m.operatorKeyword,Unit:m.unit,"UniversalSelector NestingSelector":m.definitionOperator,"MatchOp CompareOp":m.compareOperator,"ChildOp SiblingOp, LogicOp":m.logicOperator,BinOp:m.arithmeticOperator,Important:m.modifier,Comment:m.blockComment,ColorLiteral:m.color,"ParenthesizedContent StringLiteral":m.string,":":m.punctuation,PseudoOp:m.derefOperator,"; , |":m.separator,"( )":m.paren,"[ ]":m.squareBracket,"{ }":m.brace}),XM={__proto__:null,lang:44,"nth-child":44,"nth-last-child":44,"nth-of-type":44,"nth-last-of-type":44,dir:44,"host-context":44,if:88,url:158,"url-prefix":158,domain:158,regexp:158},EM={__proto__:null,or:102,and:102,not:112,only:112,layer:212},ZM={__proto__:null,selector:118,style:124,layer:208},_M={__proto__:null,"@import":204,"@media":216,"@charset":220,"@namespace":224,"@keyframes":230,"@supports":242,"@scope":246,"@font-feature-values":252},LM={__proto__:null,to:249},Mx=an.deserialize({version:14,states:"MrQYQdOOO$TQdOOP$[O`OOO%XQaO'#CfOOQP'#Ce'#CeO%`QdO'#CgO%eQ`O'#CgO%jQaO'#FrO&eQdO'#CkO'XQaO'#CcO'cQdO'#CnOOQP'#ES'#ESOOQP'#ER'#ERO'nQdO'#ETO'yQdO'#E[O'yQdO'#E_OOQP'#Fr'#FrO)`QhO'#FQOOQS'#Fq'#FqOOQS'#FT'#FTQYQdOOO)gQdO'#EeO*vQhO'#EkO)gQdO'#EmO*}QdO'#EoO+YQdO'#ErO*[QhO'#ExO+bQdO'#EzO+mQdO'#E}O+rQaO'#CfO+yQ`O'#EbO,OQ`O'#GPO,ZQdO'#GPQOQ`OOP,eO&jO'#CaPOOO)CAa)CAaOOQP'#Ci'#CiOOQP,59R,59RO%`QdO,59ROOQP'#Cm'#CmOOQP,59V,59VO&eQdO,59VO,pQdO,59YOOQP,5:m,5:mO'nQdO,5:oO'yQdO,5:vO'yQdO,5:xO'yQdO,5:yO'yQdO'#F[O,{Q`O,58}O-TQdO'#EaOOQS,58},58}OOQP'#Cq'#CqOOQO'#EP'#EPOOQP,59Y,59YO-[Q`O,59YO-aQ`O,59YO-fQpO'#EUO-qQdO'#EVO-vQ`O'#EVO-{QpO,5:oO.iQaO,5:vO/PQaO,5:yOOQW'#D]'#D]O0OQhO'#DgO0cQhO,5;lO*[QhO'#DeO0pQ`O'#DnO0uQhO'#D{OOQW'#Fx'#FxOOQS,5;l,5;lO0zQ`O'#DhO1PQ`O'#DkOOQS-E9R-E9ROOQ['#Cv'#CvO1UQdO'#CwO1iQdO'#C|O1|QdO'#DPOOQ['#DQ'#DQO2aQ!pO'#DRO4jQ!jO,5;POOQO'#DW'#DWO-aQ`O'#DVO4zQ!nO'#FuO6}Q`O'#DXO7SQ`O'#D|OOQ['#Fu'#FuO7XQhO'#GSO7gQ`O,5;VO7lQ!bO,5;XOOQS'#Eq'#EqO7tQ`O,5;ZO7yQdO,5;ZOOQO'#Et'#EtO8RQ`O,5;^O8WQhO,5;dO'yQdO'#DjOOQS,5;f,5;fO0zQ`O,5;fO8`QdO,5;fOOQS'#Fc'#FcO8hQdO'#FPO7gQ`O,5;iO8pQdO,5:|O9QQdO'#F^O9_Q`O,5mQhO'#DoOOQW,5:Y,5:YOOQW,5:g,5:gOOQW,5:S,5:SO>wQhO,5:VO?cQ!fO'#FvOOQS'#Fv'#FvOOQS'#FV'#FVO@pQdO,59cOOQ[,59c,59cOATQdO,59hOOQ[,59h,59hOAhQdO,59kOOQ[,59k,59kOOQ[,59m,59mO)gQdO,59oOA{QhO'#EgOOQW'#Eg'#EgOBjQ`O1G0kO4sQhO1G0kOOQ[,59q,59qO*[QhO'#DZOOQ[,59s,59sOBoQ#tO,5:hOBzQhO'#F`OCXQ`O,5wQhO'#DmOISQhO'#DsOI[QhO'#DuOIaQ!jO'#F{OOQO'#F{'#F{OIlQ`O'#DxOItQ!bO'#DzOOQO'#Fz'#FzOIyQ`O1G/qOOQS-E9T-E9TOOQ[1G.}1G.}OOQ[1G/S1G/SOOQ[1G/V1G/VOOQ[1G/Z1G/ZOJOQdO,5;ROOQS7+&V7+&VOJTQ`O7+&VOJYQhO'#D[OJbQ`O,59uO*[QhO,59uOOQ[1G0S1G0SOJjQ`O1G0SOJoQhO,5;zOOQO-E9^-E9^OOQS7+&a7+&aOJ}QbO'#DROOQO'#Ew'#EwOK]Q`O'#EvOOQO'#Ev'#EvOKhQ`O'#FaOKpQdO,5;aOOQS,5;a,5;aOOQ[1G/p1G/pOOQS7+&l7+&lO7gQ`O7+&lOK{Q!fO'#F]O)gQdO'#F]OMSQdO7+&SOOQO7+&S7+&SOOQO,5;O,5;OOOQO1G1d1G1dOMgQ!bO<wQhO'#DtOOQO,5:_,5:_O! hQhO,5:aO! pQhO,5:fO)gQdO,5:dOOQW7+%]7+%]OOQO'#Ei'#EiO! wQ`O1G0mOOQS<{AN>{O!$RQ`OAN>{O!$WQaO,5;uOOQO-E9X-E9XO!$bQdO,5;tOOQO-E9W-E9WOOQW<wQhO'#DwOOQO1G/{1G/{O!&UQ!bO1G0QO!&^QdO1G0OOJOQdO'#F_O!&eQ`O7+&XOOQW7+&X7+&XO!&mQ!bO1G/bOOQ[7+${7+${O!&xQhO7+${P!'PQ`O'#FWOOQO,5;|,5;|OOQO-E9`-E9`OOQS1G1g1G1gOOQPG24gG24gO!'UQ`OAN>YO)gQdO1G1_O!'ZQ`O7+'nOOQO1G/z1G/zO!'cQ`O,5:cO!'hQhO7+%lOOQO,5;y,5;yOOQO-E9]-E9]OOQW<^!a!b%Z!b!c?_!c!k%Z!k!lAl!l!u%Z!u!vAl!v!}%Z!}#OA}#O#P%Z#P#QB`#Q#R/w#R#]%Z#]#^Bq#^#g%Z#g#hAl#h#o%Z#o#pGU#p#qGg#q#rHO#r#sHa#s#y%Z#y#z&R#z$f%Z$f$g&R$g#BY%Z#BY#BZ&R#BZ$IS%Z$IS$I_&R$I_$I|%Z$I|$JO&R$JO$JT%Z$JT$JU&R$JU$KV%Z$KV$KW&R$KW&FU%Z&FU&FV&R&FV;'S%Z;'S;=`Hx<%lO%Z`%^SOy%jz;'S%j;'S;=`%{<%lO%j`%oS!r`Oy%jz;'S%j;'S;=`%{<%lO%j`&OP;=`<%l%j~&Wh$`~OX%jX^'r^p%jpq'rqy%jz#y%j#y#z'r#z$f%j$f$g'r$g#BY%j#BY#BZ'r#BZ$IS%j$IS$I_'r$I_$I|%j$I|$JO'r$JO$JT%j$JT$JU'r$JU$KV%j$KV$KW'r$KW&FU%j&FU&FV'r&FV;'S%j;'S;=`%{<%lO%j~'yh$`~!r`OX%jX^'r^p%jpq'rqy%jz#y%j#y#z'r#z$f%j$f$g'r$g#BY%j#BY#BZ'r#BZ$IS%j$IS$I_'r$I_$I|%j$I|$JO'r$JO$JT%j$JT$JU'r$JU$KV%j$KV$KW'r$KW&FU%j&FU&FV'r&FV;'S%j;'S;=`%{<%lO%jj)jS$uYOy%jz;'S%j;'S;=`%{<%lO%j~)yWOY)vZr)vrs*cs#O)v#O#P*h#P;'S)v;'S;=`+d<%lO)v~*hOv~~*kRO;'S)v;'S;=`*t;=`O)v~*wXOY)vZr)vrs*cs#O)v#O#P*h#P;'S)v;'S;=`+d;=`<%l)v<%lO)v~+gP;=`<%l)vj+maOy%jz}%j}!O,r!O!Q%j!Q![,r![!c%j!c!},r!}#O%j#O#P.O#P#R%j#R#S,r#S#T%j#T#o,r#o$g%j$g;'S,r;'S;=`/q<%lO,rj,ya$rY!r`Oy%jz}%j}!O,r!O!Q%j!Q![,r![!c%j!c!},r!}#O%j#O#P.O#P#R%j#R#S,r#S#T%j#T#o,r#o$g%j$g;'S,r;'S;=`/q<%lO,rj.TV!r`OY,rYZ%jZy,ryz.jz;'S,r;'S;=`/q<%lO,rY.oX$rY}!O.j!Q![.j!c!}.j#O#P/[#R#S.j#T#o.j$g;'S.j;'S;=`/k<%lO.jY/_SOY.jZ;'S.j;'S;=`/k<%lO.jY/nP;=`<%l.jj/tP;=`<%l,rd/zUOy%jz!_%j!_!`0^!`;'S%j;'S;=`%{<%lO%jd0eS!|S!r`Oy%jz;'S%j;'S;=`%{<%lO%jb0vS^QOy%jz;'S%j;'S;=`%{<%lO%j~1VWOY1SZw1Swx*cx#O1S#O#P1o#P;'S1S;'S;=`2k<%lO1S~1rRO;'S1S;'S;=`1{;=`O1S~2OXOY1SZw1Swx*cx#O1S#O#P1o#P;'S1S;'S;=`2k;=`<%l1S<%lO1S~2nP;=`<%l1Sj2vShYOy%jz;'S%j;'S;=`%{<%lO%j~3XOg~n3`UWQxWOy%jz!_%j!_!`0^!`;'S%j;'S;=`%{<%lO%jj3yWxW#SQOy%jz!O%j!O!P4c!P!Q%j!Q![7h![;'S%j;'S;=`%{<%lO%jj4hU!r`Oy%jz!Q%j!Q![4z![;'S%j;'S;=`%{<%lO%jj5RY!r`$kYOy%jz!Q%j!Q![4z![!g%j!g!h5q!h#X%j#X#Y5q#Y;'S%j;'S;=`%{<%lO%jj5vY!r`Oy%jz{%j{|6f|}%j}!O6f!O!Q%j!Q![6}![;'S%j;'S;=`%{<%lO%jj6kU!r`Oy%jz!Q%j!Q![6}![;'S%j;'S;=`%{<%lO%jj7UU!r`$kYOy%jz!Q%j!Q![6}![;'S%j;'S;=`%{<%lO%jj7o[!r`$kYOy%jz!O%j!O!P4z!P!Q%j!Q![7h![!g%j!g!h5q!h#X%j#X#Y5q#Y;'S%j;'S;=`%{<%lO%jj8jS!eYOy%jz;'S%j;'S;=`%{<%lO%jj8{WxWOy%jz!O%j!O!P4c!P!Q%j!Q![7h![;'S%j;'S;=`%{<%lO%jj9jU`YOy%jz!Q%j!Q![4z![;'S%j;'S;=`%{<%lO%j~:RTxWOy%jz{:b{;'S%j;'S;=`%{<%lO%j~:iS!r`$a~Oy%jz;'S%j;'S;=`%{<%lO%jj:z[$kYOy%jz!O%j!O!P4z!P!Q%j!Q![7h![!g%j!g!h5q!h#X%j#X#Y5q#Y;'S%j;'S;=`%{<%lO%jj;uUcYOy%jz![%j![!]QS!WW!|SOy%jz;'S%j;'S;=`%{<%lO%jj>eV#PQ!WWOy%jz!_%j!_!`=f!`!a>z!a;'S%j;'S;=`%{<%lO%jb?RS#PQ!r`Oy%jz;'S%j;'S;=`%{<%lO%jj?bYOy%jz}%j}!O@Q!O!c%j!c!}@o!}#T%j#T#o@o#o;'S%j;'S;=`%{<%lO%jj@VW!r`Oy%jz!c%j!c!}@o!}#T%j#T#o@o#o;'S%j;'S;=`%{<%lO%jj@v[lY!r`Oy%jz}%j}!O@o!O!Q%j!Q![@o![!c%j!c!}@o!}#T%j#T#o@o#o;'S%j;'S;=`%{<%lO%jhAqS!}WOy%jz;'S%j;'S;=`%{<%lO%jjBSSoYOy%jz;'S%j;'S;=`%{<%lO%jnBeSn^Oy%jz;'S%j;'S;=`%{<%lO%jjBvU!}WOy%jz#a%j#a#bCY#b;'S%j;'S;=`%{<%lO%jbC_U!r`Oy%jz#d%j#d#eCq#e;'S%j;'S;=`%{<%lO%jbCvU!r`Oy%jz#c%j#c#dDY#d;'S%j;'S;=`%{<%lO%jbD_U!r`Oy%jz#f%j#f#gDq#g;'S%j;'S;=`%{<%lO%jbDvU!r`Oy%jz#h%j#h#iEY#i;'S%j;'S;=`%{<%lO%jbE_U!r`Oy%jz#T%j#T#UEq#U;'S%j;'S;=`%{<%lO%jbEvU!r`Oy%jz#b%j#b#cFY#c;'S%j;'S;=`%{<%lO%jbF_U!r`Oy%jz#h%j#h#iFq#i;'S%j;'S;=`%{<%lO%jbFxS$tQ!r`Oy%jz;'S%j;'S;=`%{<%lO%jjGZSrYOy%jz;'S%j;'S;=`%{<%lO%jfGlU$gUOy%jz!_%j!_!`0^!`;'S%j;'S;=`%{<%lO%jjHTSqYOy%jz;'S%j;'S;=`%{<%lO%jfHfU#SQOy%jz!_%j!_!`0^!`;'S%j;'S;=`%{<%lO%j`H{P;=`<%l%Z",tokenizers:[AM,MM,CM,TM,1,2,3,4,new ln("m~RRYZ[z{a~~g~aO$c~~dP!P!Qg~lO$d~~",28,156)],topRules:{StyleSheet:[0,6],Styles:[1,129]},dynamicPrecedences:{97:1},specialized:[{term:172,get:(i,e)=>$x(i)<<1,external:$x},{term:150,get:i=>XM[i]||-1},{term:151,get:i=>EM[i]||-1},{term:4,get:i=>ZM[i]||-1},{term:28,get:i=>_M[i]||-1},{term:149,get:i=>LM[i]||-1}],tokenPrec:2433});var nO=null;function rO(){if(!nO&&typeof document=="object"&&document.body){let{style:i}=document.body,e=[],t=new Set;for(let n in i)n!="cssText"&&n!="cssFloat"&&typeof i[n]=="string"&&(/[A-Z]/.test(n)&&(n=n.replace(/[A-Z]/g,r=>"-"+r.toLowerCase())),t.has(n)||(e.push(n),t.add(n)));nO=e.sort().map(n=>({type:"property",label:n,apply:n+": "}))}return nO||[]}var Rx=["active","after","any-link","autofill","backdrop","before","checked","cue","default","defined","disabled","empty","enabled","file-selector-button","first","first-child","first-letter","first-line","first-of-type","focus","focus-visible","focus-within","fullscreen","has","host","host-context","hover","in-range","indeterminate","invalid","is","lang","last-child","last-of-type","left","link","marker","modal","not","nth-child","nth-last-child","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","part","placeholder","placeholder-shown","read-only","read-write","required","right","root","scope","selection","slotted","target","target-text","valid","visited","where"].map(i=>({type:"class",label:i})),Xx=["above","absolute","activeborder","additive","activecaption","after-white-space","ahead","alias","all","all-scroll","alphabetic","alternate","always","antialiased","appworkspace","asterisks","attr","auto","auto-flow","avoid","avoid-column","avoid-page","avoid-region","axis-pan","background","backwards","baseline","below","bidi-override","blink","block","block-axis","bold","bolder","border","border-box","both","bottom","break","break-all","break-word","bullets","button","button-bevel","buttonface","buttonhighlight","buttonshadow","buttontext","calc","capitalize","caps-lock-indicator","caption","captiontext","caret","cell","center","checkbox","circle","cjk-decimal","clear","clip","close-quote","col-resize","collapse","color","color-burn","color-dodge","column","column-reverse","compact","condensed","contain","content","contents","content-box","context-menu","continuous","copy","counter","counters","cover","crop","cross","crosshair","currentcolor","cursive","cyclic","darken","dashed","decimal","decimal-leading-zero","default","default-button","dense","destination-atop","destination-in","destination-out","destination-over","difference","disc","discard","disclosure-closed","disclosure-open","document","dot-dash","dot-dot-dash","dotted","double","down","e-resize","ease","ease-in","ease-in-out","ease-out","element","ellipse","ellipsis","embed","end","ethiopic-abegede-gez","ethiopic-halehame-aa-er","ethiopic-halehame-gez","ew-resize","exclusion","expanded","extends","extra-condensed","extra-expanded","fantasy","fast","fill","fill-box","fixed","flat","flex","flex-end","flex-start","footnotes","forwards","from","geometricPrecision","graytext","grid","groove","hand","hard-light","help","hidden","hide","higher","highlight","highlighttext","horizontal","hsl","hsla","hue","icon","ignore","inactiveborder","inactivecaption","inactivecaptiontext","infinite","infobackground","infotext","inherit","initial","inline","inline-axis","inline-block","inline-flex","inline-grid","inline-table","inset","inside","intrinsic","invert","italic","justify","keep-all","landscape","large","larger","left","level","lighter","lighten","line-through","linear","linear-gradient","lines","list-item","listbox","listitem","local","logical","loud","lower","lower-hexadecimal","lower-latin","lower-norwegian","lowercase","ltr","luminosity","manipulation","match","matrix","matrix3d","medium","menu","menutext","message-box","middle","min-intrinsic","mix","monospace","move","multiple","multiple_mask_images","multiply","n-resize","narrower","ne-resize","nesw-resize","no-close-quote","no-drop","no-open-quote","no-repeat","none","normal","not-allowed","nowrap","ns-resize","numbers","numeric","nw-resize","nwse-resize","oblique","opacity","open-quote","optimizeLegibility","optimizeSpeed","outset","outside","outside-shape","overlay","overline","padding","padding-box","painted","page","paused","perspective","pinch-zoom","plus-darker","plus-lighter","pointer","polygon","portrait","pre","pre-line","pre-wrap","preserve-3d","progress","push-button","radial-gradient","radio","read-only","read-write","read-write-plaintext-only","rectangle","region","relative","repeat","repeating-linear-gradient","repeating-radial-gradient","repeat-x","repeat-y","reset","reverse","rgb","rgba","ridge","right","rotate","rotate3d","rotateX","rotateY","rotateZ","round","row","row-resize","row-reverse","rtl","run-in","running","s-resize","sans-serif","saturation","scale","scale3d","scaleX","scaleY","scaleZ","screen","scroll","scrollbar","scroll-position","se-resize","self-start","self-end","semi-condensed","semi-expanded","separate","serif","show","single","skew","skewX","skewY","skip-white-space","slide","slider-horizontal","slider-vertical","sliderthumb-horizontal","sliderthumb-vertical","slow","small","small-caps","small-caption","smaller","soft-light","solid","source-atop","source-in","source-out","source-over","space","space-around","space-between","space-evenly","spell-out","square","start","static","status-bar","stretch","stroke","stroke-box","sub","subpixel-antialiased","svg_masks","super","sw-resize","symbolic","symbols","system-ui","table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row","table-row-group","text","text-bottom","text-top","textarea","textfield","thick","thin","threeddarkshadow","threedface","threedhighlight","threedlightshadow","threedshadow","to","top","transform","translate","translate3d","translateX","translateY","translateZ","transparent","ultra-condensed","ultra-expanded","underline","unidirectional-pan","unset","up","upper-latin","uppercase","url","var","vertical","vertical-text","view-box","visible","visibleFill","visiblePainted","visibleStroke","visual","w-resize","wait","wave","wider","window","windowframe","windowtext","words","wrap","wrap-reverse","x-large","x-small","xor","xx-large","xx-small"].map(i=>({type:"keyword",label:i})).concat(["aliceblue","antiquewhite","aqua","aquamarine","azure","beige","bisque","black","blanchedalmond","blue","blueviolet","brown","burlywood","cadetblue","chartreuse","chocolate","coral","cornflowerblue","cornsilk","crimson","cyan","darkblue","darkcyan","darkgoldenrod","darkgray","darkgreen","darkkhaki","darkmagenta","darkolivegreen","darkorange","darkorchid","darkred","darksalmon","darkseagreen","darkslateblue","darkslategray","darkturquoise","darkviolet","deeppink","deepskyblue","dimgray","dodgerblue","firebrick","floralwhite","forestgreen","fuchsia","gainsboro","ghostwhite","gold","goldenrod","gray","grey","green","greenyellow","honeydew","hotpink","indianred","indigo","ivory","khaki","lavender","lavenderblush","lawngreen","lemonchiffon","lightblue","lightcoral","lightcyan","lightgoldenrodyellow","lightgray","lightgreen","lightpink","lightsalmon","lightseagreen","lightskyblue","lightslategray","lightsteelblue","lightyellow","lime","limegreen","linen","magenta","maroon","mediumaquamarine","mediumblue","mediumorchid","mediumpurple","mediumseagreen","mediumslateblue","mediumspringgreen","mediumturquoise","mediumvioletred","midnightblue","mintcream","mistyrose","moccasin","navajowhite","navy","oldlace","olive","olivedrab","orange","orangered","orchid","palegoldenrod","palegreen","paleturquoise","palevioletred","papayawhip","peachpuff","peru","pink","plum","powderblue","purple","rebeccapurple","red","rosybrown","royalblue","saddlebrown","salmon","sandybrown","seagreen","seashell","sienna","silver","skyblue","slateblue","slategray","snow","springgreen","steelblue","tan","teal","thistle","tomato","turquoise","violet","wheat","white","whitesmoke","yellow","yellowgreen"].map(i=>({type:"constant",label:i}))),DM=["a","abbr","address","article","aside","b","bdi","bdo","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","dd","del","details","dfn","dialog","div","dl","dt","em","figcaption","figure","footer","form","header","hgroup","h1","h2","h3","h4","h5","h6","hr","html","i","iframe","img","input","ins","kbd","label","legend","li","main","meter","nav","ol","output","p","pre","ruby","section","select","small","source","span","strong","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","tr","u","ul"].map(i=>({type:"type",label:i})),jM=["@charset","@color-profile","@container","@counter-style","@font-face","@font-feature-values","@font-palette-values","@import","@keyframes","@layer","@media","@namespace","@page","@position-try","@property","@scope","@starting-style","@supports","@view-transition"].map(i=>({type:"keyword",label:i})),Ti=/^(\w[\w-]*|-\w[\w-]*|)$/,IM=/^-(-[\w-]*)?$/;function UM(i,e){var t;if((i.name=="("||i.type.isError)&&(i=i.parent||i),i.name!="ArgList")return!1;let n=(t=i.parent)===null||t===void 0?void 0:t.firstChild;return n?.name!="Callee"?!1:e.sliceString(n.from,n.to)=="var"}var Ex=new rs,BM=["Declaration"];function VM(i){for(let e=i;;){if(e.type.isTop)return e;if(!(e=e.parent))return i}}function Zx(i,e,t){if(e.to-e.from>4096){let n=Ex.get(e);if(n)return n;let r=[],s=new Set,o=e.cursor(F.IncludeAnonymous);if(o.firstChild())do for(let l of Zx(i,o.node,t))s.has(l.label)||(s.add(l.label),r.push(l));while(o.nextSibling());return Ex.set(e,r),r}else{let n=[],r=new Set;return e.cursor().iterate(s=>{var o;if(t(s)&&s.matchContext(BM)&&((o=s.node.nextSibling)===null||o===void 0?void 0:o.name)==":"){let l=i.sliceString(s.from,s.to);r.has(l)||(r.add(l),n.push({label:l,type:"variable"}))}}),n}}var YM=i=>e=>{let{state:t,pos:n}=e,r=ee(t).resolveInner(n,-1),s=r.type.isError&&r.from==r.to-1&&t.doc.sliceString(r.from,r.to)=="-";if(r.name=="PropertyName"||(s||r.name=="TagName")&&/^(Block|Styles)$/.test(r.resolve(r.to).name))return{from:r.from,options:rO(),validFor:Ti};if(r.name=="ValueName")return{from:r.from,options:Xx,validFor:Ti};if(r.name=="PseudoClassName")return{from:r.from,options:Rx,validFor:Ti};if(i(r)||(e.explicit||s)&&UM(r,t.doc))return{from:i(r)||s?r.from:n,options:Zx(t.doc,VM(r),i),validFor:IM};if(r.name=="TagName"){for(let{parent:a}=r;a;a=a.parent)if(a.name=="Block")return{from:r.from,options:rO(),validFor:Ti};return{from:r.from,options:DM,validFor:Ti}}if(r.name=="AtKeyword")return{from:r.from,options:jM,validFor:Ti};if(!e.explicit)return null;let o=r.resolve(n),l=o.childBefore(n);return l&&l.name==":"&&o.name=="PseudoClassSelector"?{from:n,options:Rx,validFor:Ti}:l&&l.name==":"&&o.name=="Declaration"||o.name=="ArgList"?{from:n,options:Xx,validFor:Ti}:o.name=="Block"||o.name=="Styles"?{from:n,options:rO(),validFor:Ti}:null},zM=YM(i=>i.name=="VariableName"),Jo=Hi.define({name:"css",parser:Mx.configure({props:[$i.add({Declaration:os()}),fi.add({"Block KeyframeList":Sc})]}),languageData:{commentTokens:{block:{open:"/*",close:"*/"}},indentOnInput:/^\s*\}$/,wordChars:"-"}});function _x(){return new Yt(Jo,Jo.data.of({autocomplete:zM}))}var WM=317,NM=318,Lx=1,qM=2,GM=3,FM=4,HM=319,KM=321,JM=322,e5=5,t5=6,i5=0,oO=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],Dx=125,n5=59,lO=47,r5=42,s5=43,o5=45,l5=60,a5=44,c5=63,h5=46,f5=91,u5=new Os({start:!1,shift(i,e){return e==e5||e==t5||e==KM?i:e==JM},strict:!1}),d5=new Me((i,e)=>{let{next:t}=i;(t==Dx||t==-1||e.context)&&i.acceptToken(HM)},{contextual:!0,fallback:!0}),p5=new Me((i,e)=>{let{next:t}=i,n;oO.indexOf(t)>-1||t==lO&&((n=i.peek(1))==lO||n==r5)||t!=Dx&&t!=n5&&t!=-1&&!e.context&&i.acceptToken(WM)},{contextual:!0}),O5=new Me((i,e)=>{i.next==f5&&!e.context&&i.acceptToken(NM)},{contextual:!0}),g5=new Me((i,e)=>{let{next:t}=i;if(t==s5||t==o5){if(i.advance(),t==i.next){i.advance();let n=!e.context&&e.canShift(Lx);i.acceptToken(n?Lx:qM)}}else t==c5&&i.peek(1)==h5&&(i.advance(),i.advance(),(i.next<48||i.next>57)&&i.acceptToken(GM))},{contextual:!0});function sO(i,e){return i>=65&&i<=90||i>=97&&i<=122||i==95||i>=192||!e&&i>=48&&i<=57}var m5=new Me((i,e)=>{if(i.next!=l5||!e.dialectEnabled(i5)||(i.advance(),i.next==lO))return;let t=0;for(;oO.indexOf(i.next)>-1;)i.advance(),t++;if(sO(i.next,!0)){for(i.advance(),t++;sO(i.next,!1);)i.advance(),t++;for(;oO.indexOf(i.next)>-1;)i.advance(),t++;if(i.next==a5)return;for(let n=0;;n++){if(n==7){if(!sO(i.next,!0))return;break}if(i.next!="extends".charCodeAt(n))break;i.advance(),t++}}i.acceptToken(FM,-t)}),b5=Bt({"get set async static":m.modifier,"for while do if else switch try catch finally return throw break continue default case defer":m.controlKeyword,"in of await yield void typeof delete instanceof as satisfies":m.operatorKeyword,"let var const using function class extends":m.definitionKeyword,"import export from":m.moduleKeyword,"with debugger new":m.keyword,TemplateString:m.special(m.string),super:m.atom,BooleanLiteral:m.bool,this:m.self,null:m.null,Star:m.modifier,VariableName:m.variableName,"CallExpression/VariableName TaggedTemplateExpression/VariableName":m.function(m.variableName),VariableDefinition:m.definition(m.variableName),Label:m.labelName,PropertyName:m.propertyName,PrivatePropertyName:m.special(m.propertyName),"CallExpression/MemberExpression/PropertyName":m.function(m.propertyName),"FunctionDeclaration/VariableDefinition":m.function(m.definition(m.variableName)),"ClassDeclaration/VariableDefinition":m.definition(m.className),"NewExpression/VariableName":m.className,PropertyDefinition:m.definition(m.propertyName),PrivatePropertyDefinition:m.definition(m.special(m.propertyName)),UpdateOp:m.updateOperator,"LineComment Hashbang":m.lineComment,BlockComment:m.blockComment,Number:m.number,String:m.string,Escape:m.escape,ArithOp:m.arithmeticOperator,LogicOp:m.logicOperator,BitOp:m.bitwiseOperator,CompareOp:m.compareOperator,RegExp:m.regexp,Equals:m.definitionOperator,Arrow:m.function(m.punctuation),": Spread":m.punctuation,"( )":m.paren,"[ ]":m.squareBracket,"{ }":m.brace,"InterpolationStart InterpolationEnd":m.special(m.brace),".":m.derefOperator,", ;":m.separator,"@":m.meta,TypeName:m.typeName,TypeDefinition:m.definition(m.typeName),"type enum interface implements namespace module declare":m.definitionKeyword,"abstract global Privacy readonly override":m.modifier,"is keyof unique infer asserts":m.operatorKeyword,JSXAttributeValue:m.attributeValue,JSXText:m.content,"JSXStartTag JSXStartCloseTag JSXSelfCloseEndTag JSXEndTag":m.angleBracket,"JSXIdentifier JSXNameSpacedName":m.tagName,"JSXAttribute/JSXIdentifier JSXAttribute/JSXNameSpacedName":m.attributeName,"JSXBuiltin/JSXIdentifier":m.standard(m.tagName)}),y5={__proto__:null,export:20,as:25,from:33,default:36,async:41,function:42,in:52,out:55,const:56,extends:60,this:64,true:72,false:72,null:84,void:88,typeof:92,super:108,new:142,delete:154,yield:163,await:167,class:172,public:237,private:237,protected:237,readonly:239,instanceof:258,satisfies:261,import:294,keyof:351,unique:355,infer:361,asserts:397,is:399,abstract:419,implements:421,type:423,let:426,var:428,using:431,interface:437,enum:441,namespace:447,module:449,declare:453,global:457,defer:473,for:478,of:487,while:490,with:494,do:498,if:502,else:504,switch:508,case:514,try:520,catch:524,finally:528,return:532,throw:536,break:540,continue:544,debugger:548},S5={__proto__:null,async:129,get:131,set:133,declare:195,public:197,private:197,protected:197,static:199,abstract:201,override:203,readonly:209,accessor:211,new:403},x5={__proto__:null,"<":193},jx=an.deserialize({version:14,states:"$FvQ%TQlOOO%[QlOOO'_QpOOP(lO`OOO*zQ!0MxO'#CiO+RO#tO'#CjO+aO&jO'#CjO+oO#@ItO'#DaO.QQlO'#DgO.bQlO'#DrO%[QlO'#DzO0fQlO'#ESOOQ!0Lf'#E['#E[O1PQ`O'#EXOOQO'#Ep'#EpOOQO'#Im'#ImO1XQ`O'#GtO1dQ`O'#EoO1iQ`O'#EoO3hQ!0MxO'#JsO6[Q!0MxO'#JtO6uQ`O'#F^O6zQ,UO'#FuOOQ!0Lf'#Fg'#FgO7VO7dO'#FgO9XQMhO'#F}O9`Q`O'#F|OOQ!0Lf'#Jt'#JtOOQ!0Lb'#Js'#JsO9eQ`O'#GxOOQ['#K`'#K`O9pQ`O'#IZO9uQ!0LrO'#I[OOQ['#Ja'#JaOOQ['#I`'#I`Q`QlOOQ`QlOOO9}Q!L^O'#DvO:UQlO'#EOO:]QlO'#EQO9kQ`O'#GtO:dQMhO'#CoO:rQ`O'#EnO:}Q`O'#EzO;hQMhO'#FfO;xQ`O'#GtOOQO'#Ka'#KaO;}Q`O'#KaO<]Q`O'#G|O<]Q`O'#G}O<]Q`O'#HPO9kQ`O'#HSO=SQ`O'#HVO>kQ`O'#CeO>{Q`O'#HdO?TQ`O'#HjO?TQ`O'#HlO`QlO'#HnO?TQ`O'#HpO?TQ`O'#HsO?YQ`O'#HyO?_Q!0LsO'#IPO%[QlO'#IRO?jQ!0LsO'#ITO?uQ!0LsO'#IVO9uQ!0LrO'#IXO@QQ!0MxO'#CiOASQpO'#DlQOQ`OOO%[QlO'#EQOAjQ`O'#ETO:dQMhO'#EnOAuQ`O'#EnOBQQ!bO'#FfOOQ['#Cg'#CgOOQ!0Lb'#Dq'#DqOOQ!0Lb'#Jw'#JwO%[QlO'#JwOOQO'#Jz'#JzOOQO'#Ii'#IiOCQQpO'#EgOOQ!0Lb'#Ef'#EfOOQ!0Lb'#KO'#KOOC|Q!0MSO'#EgODWQpO'#EWOOQO'#Jy'#JyODlQpO'#JzOEyQpO'#EWODWQpO'#EgPFWO&2DjO'#CbPOOO)CEO)CEOOOOO'#Ia'#IaOFcO#tO,59UOOQ!0Lh,59U,59UOOOO'#Ib'#IbOFqO&jO,59UOGPQ!L^O'#DcOOOO'#Id'#IdOGWO#@ItO,59{OOQ!0Lf,59{,59{OGfQlO'#IeOGyQ`O'#JuOIxQ!fO'#JuO+}QlO'#JuOJPQ`O,5:ROJgQ`O'#EpOJtQ`O'#KUOKPQ`O'#KTOKPQ`O'#KTOKXQ`O,5;^OK^Q`O'#KSOOQ!0Ln,5:^,5:^OKeQlO,5:^OMcQ!0MxO,5:fONSQ`O,5:nONmQ!0LrO'#KRONtQ`O'#KQO9eQ`O'#KQO! YQ`O'#KQO! bQ`O,5;]O! gQ`O'#KQO!#lQ!fO'#JtOOQ!0Lh'#Ci'#CiO%[QlO'#ESO!$[Q!fO,5:sOOQS'#J{'#J{OOQO-EuOOQ['#Ji'#JiOOQ[,5>v,5>vOOQ[-E<^-E<^O!OO!LOQ`O,5>OO`QlO,5>UO`QlO,5>WO!LTQ`O,5>YO`QlO,5>[O!LYQ`O,5>_O!L_QlO,5>eOOQ[,5>k,5>kO%[QlO,5>kO9uQ!0LrO,5>mOOQ[,5>o,5>oO#!iQ`O,5>oOOQ[,5>q,5>qO#!iQ`O,5>qOOQ[,5>s,5>sO##SQpO'#D_O%[QlO'#JwO##^QpO'#JwO##xQpO'#DmO#$ZQpO'#DmO#&lQlO'#DmO#&sQ`O'#JvO#&{Q`O,5:WOOQO'#Et'#EtO#'QQ`O'#EtO#'VQ`O'#KVO#'_Q`O,5;_O#'dQpO'#DmO#'qQpO'#EVOOQ!0Lf,5:o,5:oO%[QlO,5:oO#'xQ`O,5:oO?YQ`O,5;YO!CZQpO,5;YO!CcQMhO,5;YO:dQMhO,5;YO#(QQ`O,5@cO#(VQ07dO,5:sOOQO-EQO$6ZQ`O,5>QOOQ[1G3j1G3jO`QlO1G3jOOQ[1G3p1G3pOOQ[1G3r1G3rO?TQ`O1G3tO$6`QlO1G3vO$:dQlO'#HuOOQ[1G3y1G3yO$:qQ`O'#H{O?YQ`O'#H}OOQ[1G4P1G4PO$:yQlO1G4PO9uQ!0LrO1G4VOOQ[1G4X1G4XOOQ!0Lb'#G`'#G`O9uQ!0LrO1G4ZO9uQ!0LrO1G4]O$?QQ`O,5@cO9eQ`O,5;`O?YQ`O,5:XO!)[QlO,5:XO!CZQpO,5:XO$?VQ?MtO,5:XOOQO,5;`,5;`O$?aQpO'#IfO$?wQ`O,5@bOOQ!0Lf1G/r1G/rO$@PQpO'#IlO$@ZQ`O,5@qOOQ!0Lb1G0y1G0yO#$ZQpO,5:XOOQO'#Ih'#IhO$@cQpO,5:qOOQ!0Ln,5:q,5:qO#'{Q`O1G0ZOOQ!0Lf1G0Z1G0ZO%[QlO1G0ZOOQ!0Lf1G0t1G0tO?YQ`O1G0tO!CZQpO1G0tO!CcQMhO1G0tOOQ!0Lb1G5}1G5}O!COQ!0LrO1G0^OOQO1G0m1G0mO%[QlO1G0mO$@jQ!0LrO1G0mO$@uQ!0LrO1G0mO!CZQpO1G0^ODWQpO1G0^O$ATQ!0LrO1G0mOOQO1G0^1G0^O$AiQ!0MxO1G0mPOOO-E<]-E<]POOO1G.h1G.hOOOO1G/i1G/iO$AsQ!bO,5YQ`O7+$VO%[QlO1G6mO%[QlO1G6nO%>_Q!0LrO1G6mO%>iQlO1G3lO%>pQ`O1G3lO%>uQlO1G3lOOQ[7+)U7+)UO9uQ!0LrO7+)`O`QlO7+)bOOQ['#Ki'#KiOOQ['#JT'#JTO%>|QlO,5>aOOQ[,5>a,5>aO%[QlO'#HvO%?ZQ`O'#HxOOQ[,5>g,5>gO9eQ`O,5>gOOQ[,5>i,5>iOOQ[7+)k7+)kOOQ[7+)q7+)qOOQ[7+)u7+)uOOQ[7+)w7+)wO%?`QpO1G5}O%?zQ`O1G0zOOQO1G/s1G/sO%@VQ?MtO1G/sO?YQ`O1G/sO!)[QlO'#DmOOQO,5?Q,5?QOOQO-EOQ`O7+)WO&>TQlO7+)WO&>[Q`O7+)WOOQ[<aQ`O,5>bOOQ[,5>d,5>dO&>fQ`O1G4RO9eQ`O7+&fO!)[QlO7+&fOOQO7+%_7+%_O&>kQ?MtO1G6[O?YQ`O7+%_OOQ!0Lf<uQ!0LrO<lQ?MvO,5?bO'@oQ?MvO,5?dO'BrQ?MvO7+'}O'DhQMjOG27UOOQO<UO!l$xO#kQOe!iOpkOrOO(U)^O(WTO(ZUO(bVO(p[O~O!]$_Oa$ra'{$ra'x$ra!k$ra!Y$ra!_$ra%j$ra!g$ra~Ol)eO~P!&zOh%VOp%WOr%XOs$tOt$tOz%YO|%ZO!O%]O!S${O!_$|O!i%bO!l$xO#k%cO$X%`O$u%^O$w%_O$z%aO(U(wO(WTO(ZUO(b$uO(z$}O({%PO~Og(qP~P!,TO!Q)jO!g)iO!_$_X$[$_X$^$_X$`$_X$g$_X~O!g)iO!_(|X$[(|X$^(|X$`(|X$g(|X~O!Q)jO~P!.^O!Q)jO!_(|X$[(|X$^(|X$`(|X$g(|X~O!_)lO$[)pO$^)kO$`)kO$g)qO~O![)tO~P!)[O$^$hO$`$gO$g)xO~On${X!Q${X#S${X'z${X(z${X({${X~OgmXg${XnmX!]mX#`mX~P!0SOx)zO(c){O(d)}O~On*WO!Q*PO'z*QO(z$}O({%PO~Og*OO~P!1WOg*XO~Oh%VOr%XOs$tOt$tOz%YO|%ZO!OUO!l$xO#kUO!l$xO#kQOe!iOpkOrOO(WTO(ZUO(bVO(p[O~O(U=PO~P#$lO!]-^O!^(jX~O!^-`O~O!g-WO~O!]-aO!^(yX~O!^-cO~O!c-dO!d-dO(V!lO~P#$ZO!^-gO~P'_On-jO!_'aO~O!Y-oO~Os!{a!b!{a!c!{a!d!{a#T!{a#U!{a#V!{a#W!{a#X!{a#[!{a#]!{a(V!{a(W!{a(Z!{a(f!{a(p!{a~P!#vO!p-tO#`-rO~PChO!c-vO!d-vO(V!lO~PDWOa%nO#`-rO'{%nO~Oa%nO!g#vO#`-rO'{%nO~Oa%nO!g#vO!p-tO#`-rO'{%nO(s'qO~O(Q'yO(R'yO(S-{O~Ov-|O~O!Y'Xa!]'Xa~P!:tO![.QO!Y'XX!]'XX~P%[O!](WO!Y(ia~O!Y(ia~PHRO!](_O!Y(wa~O!S%hO![.UO!_%iO(U%gO!Y'_X!]'_X~O#`.WO!](ua!k(uaa(ua'{(ua~O!g#vO~P#,iO!](kO!k(ta~O!S%hO!_%iO#k.[O(U%gO~Op.aO!S%hO![.^O!_%iO!|]O#j.`O#k.^O(U%gO!]'bX!k'bX~OR.eO!l#xO~Oh%VOn.hO!_'aO%j.gO~Oa#ci!]#ci'{#ci'x#ci!Y#ci!k#civ#ci!_#ci%j#ci!g#ci~P!:tOn>[O!Q*PO'z*QO(z$}O({%PO~O#l#_aa#_a#`#_a'{#_a!]#_a!k#_a!_#_a!Y#_a~P#/eO#l(aXP(aXR(aX[(aXa(aXj(aXr(aX!S(aX!l(aX!p(aX#R(aX#o(aX#p(aX#q(aX#r(aX#s(aX#t(aX#u(aX#v(aX#w(aX#y(aX#{(aX#|(aX'{(aX(b(aX(s(aX!k(aX!Y(aX'x(aXv(aX!_(aX%j(aX!g(aX~P!6kO!].uO!k(lX~P!:tO!k.xO~O!Y.zO~OP$[OR#zO!Q#yO!S#{O!l#xO!p$[O(bVO[#nia#nij#nir#ni!]#ni#R#ni#p#ni#q#ni#r#ni#s#ni#t#ni#u#ni#v#ni#w#ni#y#ni#{#ni#|#ni'{#ni(s#ni(z#ni({#ni'x#ni!Y#ni!k#niv#ni!_#ni%j#ni!g#ni~O#o#ni~P#3TO#o$OO~P#3TOP$[OR#zOr$aO!Q#yO!S#{O!l#xO!p$[O#o$OO#p$PO#q$PO#r$PO(bVO[#nia#nij#ni!]#ni#R#ni#t#ni#u#ni#v#ni#w#ni#y#ni#{#ni#|#ni'{#ni(s#ni(z#ni({#ni'x#ni!Y#ni!k#niv#ni!_#ni%j#ni!g#ni~O#s#ni~P#5rO#s$QO~P#5rOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#o$OO#p$PO#q$PO#r$PO#s$QO#t$RO#u$RO#v$bO(bVOa#ni!]#ni#y#ni#{#ni#|#ni'{#ni(s#ni(z#ni({#ni'x#ni!Y#ni!k#niv#ni!_#ni%j#ni!g#ni~O#w#ni~P#8aOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#o$OO#p$PO#q$PO#r$PO#s$QO#t$RO#u$RO#v$bO#w$SO(bVO({#}Oa#ni!]#ni#{#ni#|#ni'{#ni(s#ni(z#ni'x#ni!Y#ni!k#niv#ni!_#ni%j#ni!g#ni~O#y$UO~P#:wO#y#ni~P#:wO#w$SO~P#8aOP$[OR#zO[$cOj$ROr$aO!Q#yO!S#{O!l#xO!p$[O#R$RO#o$OO#p$PO#q$PO#r$PO#s$QO#t$RO#u$RO#v$bO#w$SO#y$UO(bVO(z#|O({#}Oa#ni!]#ni#|#ni'{#ni(s#ni'x#ni!Y#ni!k#niv#ni!_#ni%j#ni!g#ni~O#{#ni~P#=mO#{$WO~P#=mOP]XR]X[]Xj]Xr]X!Q]X!S]X!l]X!p]X#R]X#S]X#`]X#lfX#o]X#p]X#q]X#r]X#s]X#t]X#u]X#v]X#w]X#y]X#{]X#|]X$R]X(b]X(s]X(z]X({]X!]]X!^]X~O$P]X~P#@[OP$[OR#zO[[O!Q*PO'z*QO(z$}O({%POP#niR#ni!S#ni!l#ni!p#ni#o#ni#p#ni#q#ni#r#ni(b#ni~P#EkO!]/QOg(qX~P!1WOg/SO~Oa$Qi!]$Qi'{$Qi'x$Qi!Y$Qi!k$Qiv$Qi!_$Qi%j$Qi!g$Qi~P!:tO$^/TO$`/TO~O$^/UO$`/UO~O!g)iO#`/VO!_$dX$[$dX$^$dX$`$dX$g$dX~O![/WO~O!_)lO$[/YO$^)kO$`)kO$g/ZO~O!]UO!l$xO#k]O!Q*PO'z*QO(z$}O({%POP#niR#ni!S#ni!l#ni!p#ni#o#ni#p#ni#q#ni#r#ni(b#ni~P&+sO#S$dOP(aXR(aX[(aXj(aXn(aXr(aX!Q(aX!S(aX!l(aX!p(aX#R(aX#o(aX#p(aX#q(aX#r(aX#s(aX#t(aX#u(aX#v(aX#w(aX#y(aX#{(aX#|(aX$P(aX'z(aX(b(aX(s(aX(z(aX({(aX!](aX!^(aX~O$P$Qi!]$Qi!^$Qi~P#BiO$P!ri!^!ri~P$+lOg'^a!]'^a~P!1WO!^7nO~O!]'ea!^'ea~P#BiO!Y7oO~P#/eO!g#vO(s'qO!]'fa!k'fa~O!]/qO!k)Pi~O!]/qO!g#vO!k)Pi~Og$}q!]$}q#`$}q$P$}q~P!1WO!Y'ha!]'ha~P#/eO!g7vO~O!]/zO!Y)Qi~P#/eO!]/zO!Y)Qi~O!Y7yO~Oh%VOr8OO!l%eO(s'qO~Oj8QO!g#vO~Or8TO!g#vO(s'qO~O!Q*PO'z*QO({%POn'ka(z'ka!]'ka#`'ka~Og'ka$P'ka~P&4tO!Q*PO'z*QOn'ma(z'ma({'ma!]'ma#`'ma~Og'ma$P'ma~P&5gOg(`q!](`q~P!1WO#`8VOg(`q!](`q~P!1WO!Y8WO~Og%Pq!]%Pq#`%Pq$P%Pq~P!1WOa$py!]$py'{$py'x$py!Y$py!k$pyv$py!_$py%j$py!g$py~P!:tO!g6rO~O!]5[O!_)Ra~O!_'aOP$UaR$Ua[$Uaj$Uar$Ua!Q$Ua!S$Ua!]$Ua!l$Ua!p$Ua#R$Ua#o$Ua#p$Ua#q$Ua#r$Ua#s$Ua#t$Ua#u$Ua#v$Ua#w$Ua#y$Ua#{$Ua#|$Ua(b$Ua(s$Ua(z$Ua({$Ua~O%j7WO~P&8XO%_8[Oa%]i!_%]i'{%]i!]%]i~Oa#cy!]#cy'{#cy'x#cy!Y#cy!k#cyv#cy!_#cy%j#cy!g#cy~P!:tO[8^O~Ob8`O(U+sO(WTO(ZUO~O!]1UO!^)Yi~O`8dO~O(f(}O!]'qX!^'qX~O!]5uO!^)Va~O!^8nO~P%;bO(p!sO~P$%zO#[8oO~O!_1pO~O!_1pO%j8qO~On8tO!_1pO%j8qO~O[8yO!]'ta!^'ta~O!]1{O!^)Wi~O!k8}O~O!k9OO~O!k9RO~O!k9RO~P%[Oa9TO~O!g9UO~O!k9VO~O!](xi!^(xi~P#BiOa%nO#`9_O'{%nO~O!](uy!k(uya(uy'{(uy~P!:tO!](kO!k(ty~O%j9bO~P&8XO!_'aO%j9bO~O#l$}qP$}qR$}q[$}qa$}qj$}qr$}q!S$}q!]$}q!l$}q!p$}q#R$}q#o$}q#p$}q#q$}q#r$}q#s$}q#t$}q#u$}q#v$}q#w$}q#y$}q#{$}q#|$}q'{$}q(b$}q(s$}q!k$}q!Y$}q'x$}q#`$}qv$}q!_$}q%j$}q!g$}q~P#/eO#l'kaP'kaR'ka['kaa'kaj'kar'ka!S'ka!l'ka!p'ka#R'ka#o'ka#p'ka#q'ka#r'ka#s'ka#t'ka#u'ka#v'ka#w'ka#y'ka#{'ka#|'ka'{'ka(b'ka(s'ka!k'ka!Y'ka'x'kav'ka!_'ka%j'ka!g'ka~P&4tO#l'maP'maR'ma['maa'maj'mar'ma!S'ma!l'ma!p'ma#R'ma#o'ma#p'ma#q'ma#r'ma#s'ma#t'ma#u'ma#v'ma#w'ma#y'ma#{'ma#|'ma'{'ma(b'ma(s'ma!k'ma!Y'ma'x'mav'ma!_'ma%j'ma!g'ma~P&5gO#l%PqP%PqR%Pq[%Pqa%Pqj%Pqr%Pq!S%Pq!]%Pq!l%Pq!p%Pq#R%Pq#o%Pq#p%Pq#q%Pq#r%Pq#s%Pq#t%Pq#u%Pq#v%Pq#w%Pq#y%Pq#{%Pq#|%Pq'{%Pq(b%Pq(s%Pq!k%Pq!Y%Pq'x%Pq#`%Pqv%Pq!_%Pq%j%Pq!g%Pq~P#/eO!]'Zi!k'Zi~P!:tO$P#cq!]#cq!^#cq~P#BiO(z$}OP%baR%ba[%baj%bar%ba!S%ba!l%ba!p%ba#R%ba#o%ba#p%ba#q%ba#r%ba#s%ba#t%ba#u%ba#v%ba#w%ba#y%ba#{%ba#|%ba$P%ba(b%ba(s%ba!]%ba!^%ba~On%ba!Q%ba'z%ba({%ba~P&IlO({%POP%daR%da[%daj%dar%da!S%da!l%da!p%da#R%da#o%da#p%da#q%da#r%da#s%da#t%da#u%da#v%da#w%da#y%da#{%da#|%da$P%da(b%da(s%da!]%da!^%da~On%da!Q%da'z%da(z%da~P&KsOn>]O!Q*PO'z*QO({%PO~P&IlOn>]O!Q*PO'z*QO(z$}O~P&KsOR0lO!Q0lO!S0mO#S$dOP}a[}aj}an}ar}a!l}a!p}a#R}a#o}a#p}a#q}a#r}a#s}a#t}a#u}a#v}a#w}a#y}a#{}a#|}a$P}a'z}a(b}a(s}a(z}a({}a!]}a!^}a~O!Q*PO'z*QOP$taR$ta[$taj$tan$tar$ta!S$ta!l$ta!p$ta#R$ta#o$ta#p$ta#q$ta#r$ta#s$ta#t$ta#u$ta#v$ta#w$ta#y$ta#{$ta#|$ta$P$ta(b$ta(s$ta(z$ta({$ta!]$ta!^$ta~O!Q*PO'z*QOP$vaR$va[$vaj$van$var$va!S$va!l$va!p$va#R$va#o$va#p$va#q$va#r$va#s$va#t$va#u$va#v$va#w$va#y$va#{$va#|$va$P$va(b$va(s$va(z$va({$va!]$va!^$va~On>]O!Q*PO'z*QO(z$}O({%PO~OP%UaR%Ua[%Uaj%Uar%Ua!S%Ua!l%Ua!p%Ua#R%Ua#o%Ua#p%Ua#q%Ua#r%Ua#s%Ua#t%Ua#u%Ua#v%Ua#w%Ua#y%Ua#{%Ua#|%Ua$P%Ua(b%Ua(s%Ua!]%Ua!^%Ua~P'&xO$P$nq!]$nq!^$nq~P#BiO$P$pq!]$pq!^$pq~P#BiO!^9oO~O$P9pO~P!1WO!g#vO!]'fi!k'fi~O!g#vO(s'qO!]'fi!k'fi~O!]/qO!k)Pq~O!Y'hi!]'hi~P#/eO!]/zO!Y)Qq~Or9wO!g#vO(s'qO~O[9yO!Y9xO~P#/eO!Y9xO~Oj:PO!g#vO~Og(`y!](`y~P!1WO!]'oa!_'oa~P#/eOa%]q!_%]q'{%]q!]%]q~P#/eO[:UO~O!]1UO!^)Yq~O`:YO~O#`:ZO!]'qa!^'qa~O!]5uO!^)Vi~P#BiO!S:]O~O!_1pO%j:`O~O(WTO(ZUO(f:eO~O!]1{O!^)Wq~O!k:hO~O!k:iO~O!k:jO~O!k:jO~P%[O#`:mO!]#hy!^#hy~O!]#hy!^#hy~P#BiO%j:rO~P&8XO!_'aO%j:rO~O$P#}y!]#}y!^#}y~P#BiOP$}iR$}i[$}ij$}ir$}i!S$}i!l$}i!p$}i#R$}i#o$}i#p$}i#q$}i#r$}i#s$}i#t$}i#u$}i#v$}i#w$}i#y$}i#{$}i#|$}i$P$}i(b$}i(s$}i!]$}i!^$}i~P'&xO!Q*PO'z*QO({%POP'jaR'ja['jaj'jan'jar'ja!S'ja!l'ja!p'ja#R'ja#o'ja#p'ja#q'ja#r'ja#s'ja#t'ja#u'ja#v'ja#w'ja#y'ja#{'ja#|'ja$P'ja(b'ja(s'ja(z'ja!]'ja!^'ja~O!Q*PO'z*QOP'laR'la['laj'lan'lar'la!S'la!l'la!p'la#R'la#o'la#p'la#q'la#r'la#s'la#t'la#u'la#v'la#w'la#y'la#{'la#|'la$P'la(b'la(s'la(z'la({'la!]'la!^'la~O(z$}OP%biR%bi[%bij%bin%bir%bi!Q%bi!S%bi!l%bi!p%bi#R%bi#o%bi#p%bi#q%bi#r%bi#s%bi#t%bi#u%bi#v%bi#w%bi#y%bi#{%bi#|%bi$P%bi'z%bi(b%bi(s%bi({%bi!]%bi!^%bi~O({%POP%diR%di[%dij%din%dir%di!Q%di!S%di!l%di!p%di#R%di#o%di#p%di#q%di#r%di#s%di#t%di#u%di#v%di#w%di#y%di#{%di#|%di$P%di'z%di(b%di(s%di(z%di!]%di!^%di~O$P$py!]$py!^$py~P#BiO$P#cy!]#cy!^#cy~P#BiO!g#vO!]'fq!k'fq~O!]/qO!k)Py~O!Y'hq!]'hq~P#/eOr:|O!g#vO(s'qO~O[;QO!Y;PO~P#/eO!Y;PO~Og(`!R!](`!R~P!1WOa%]y!_%]y'{%]y!]%]y~P#/eO!]1UO!^)Yy~O!]5uO!^)Vq~O(U;XO~O!_1pO%j;[O~O!k;_O~O%j;dO~P&8XOP$}qR$}q[$}qj$}qr$}q!S$}q!l$}q!p$}q#R$}q#o$}q#p$}q#q$}q#r$}q#s$}q#t$}q#u$}q#v$}q#w$}q#y$}q#{$}q#|$}q$P$}q(b$}q(s$}q!]$}q!^$}q~P'&xO!Q*PO'z*QO({%POP'kaR'ka['kaj'kan'kar'ka!S'ka!l'ka!p'ka#R'ka#o'ka#p'ka#q'ka#r'ka#s'ka#t'ka#u'ka#v'ka#w'ka#y'ka#{'ka#|'ka$P'ka(b'ka(s'ka(z'ka!]'ka!^'ka~O!Q*PO'z*QOP'maR'ma['maj'man'mar'ma!S'ma!l'ma!p'ma#R'ma#o'ma#p'ma#q'ma#r'ma#s'ma#t'ma#u'ma#v'ma#w'ma#y'ma#{'ma#|'ma$P'ma(b'ma(s'ma(z'ma({'ma!]'ma!^'ma~OP%PqR%Pq[%Pqj%Pqr%Pq!S%Pq!l%Pq!p%Pq#R%Pq#o%Pq#p%Pq#q%Pq#r%Pq#s%Pq#t%Pq#u%Pq#v%Pq#w%Pq#y%Pq#{%Pq#|%Pq$P%Pq(b%Pq(s%Pq!]%Pq!^%Pq~P'&xOg%f!Z!]%f!Z#`%f!Z$P%f!Z~P!1WO!Y;hO~P#/eOr;iO!g#vO(s'qO~O[;kO!Y;hO~P#/eO!]'qq!^'qq~P#BiO!]#h!Z!^#h!Z~P#BiO#l%f!ZP%f!ZR%f!Z[%f!Za%f!Zj%f!Zr%f!Z!S%f!Z!]%f!Z!l%f!Z!p%f!Z#R%f!Z#o%f!Z#p%f!Z#q%f!Z#r%f!Z#s%f!Z#t%f!Z#u%f!Z#v%f!Z#w%f!Z#y%f!Z#{%f!Z#|%f!Z'{%f!Z(b%f!Z(s%f!Z!k%f!Z!Y%f!Z'x%f!Z#`%f!Zv%f!Z!_%f!Z%j%f!Z!g%f!Z~P#/eOr;tO!g#vO(s'qO~O!Y;uO~P#/eOr;|O!g#vO(s'qO~O!Y;}O~P#/eOP%f!ZR%f!Z[%f!Zj%f!Zr%f!Z!S%f!Z!l%f!Z!p%f!Z#R%f!Z#o%f!Z#p%f!Z#q%f!Z#r%f!Z#s%f!Z#t%f!Z#u%f!Z#v%f!Z#w%f!Z#y%f!Z#{%f!Z#|%f!Z$P%f!Z(b%f!Z(s%f!Z!]%f!Z!^%f!Z~P'&xOrQOe!iOpkOrOO(U)^O(WTO(ZUO(bVO(p[O~O!]VO!l$xO#kY!>bPPP!>hIUPPPPPPPPP!AvP!CSPPIU!DdPIUPIUIUIUIUIUPIU!EuP!IOP!LTP!LX!Lc!Lg!LgP!H{P!Lk!LkP# pP# tIUPIU# z#%OCfAYPAYPAYAYP#&]AYAY#(oAY#+gAY#-sAYAY#.c#0w#0w#0|#1V#0w#1bPP#0wPAY#1zAY#5yAYAY6jPPP#:OPPP#:i#:iP#:iP#;P#:iPP#;VP#:|P#:|#;j#:|#[#>b#>l#>r#>|#?S#?d#?j#@[#@n#@t#@z#AY#Ao#Cd#Cr#Cy#Ee#Es#Ge#Gs#Gy#HP#HV#Ha#Hg#Hm#Hw#IZ#IaPPPPPPPPPPP#IgPPPPPPP#J[#Mi$ R$ Y$ bPPP$&|P$'V$*O$0f$0i$0l$1k$1n$1u$1}P$2T$2WP$2t$2x$3p$5O$5T$5kPP$5p$5v$5z$5}$6R$6V$7R$7j$8R$8V$8Y$8]$8g$8j$8n$8rR!|RoqOXst!Z#d%m&r&t&u&w,u,z2]2`Y!vQ'a-f1p5{Q%tvQ%|yQ&T|Q&j!VS'W!e-^Q'g!iS'm!r!yU*l$|*[*pQ+q%}S,O&V&WQ,f&dQ-d'`Q-n'hQ-v'nQ0]*rQ1c,QQ1z,gR<{RU+Q%]R!S!nQ!r!v!y!z$|'W'`'a'm'n'o*l*p*r*s-^-d-f-v0]0`1p5{5}%[$ti#v$b$c$d$x${%O%Q%^%_%c)z*S*U*W*Z*b*h*x*y+h+k,U,X.g/Q/e/n/y/z/|0a0c0j0k0p1g1j1r3c4^4_4j4o5Q5[5_6S7W7v8Q8V8[8q9b9p9y:P:`:r;Q;[;d;kO>W>X>[>]Q&X|S'U!e*[S']%i-aQ+v&PQ,R&WQ,h&gQ0o+TQ1Z+wQ1`+}Q2R,lQ2S,mQ5f1UQ5o1bQ6[1{Q6_1}Q6`2QQ8`5gQ8c5lQ8|6bQ:X8dQ:f8yR;V:YrnOXst!V!Z#d%m&i&r&t&u&w,u,z2]2`R,j&k&x^OPXYstuvwz!Z!`!g!j!o#S#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n%m%t&R&k&n&o&r&t&u&w&{'T'c's(W(^(e(y({)P)t*O*j+Y+_+i,r,u,z-Y-j-r.Q.W.h.u.|/W/o0^0m0s1T1s2T2U2W2Y2]2`2b2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mQ>R[#]WZ#W#Z'X(U!b%jm#h#i#l$x%e%h(_(i(j(k*Z*_*c+[+^+`,q-W.U.[.].^.`/n/q2e3[3]4a6r7TQ%wxQ%{yW&Q|&V&W,QQ&_!TQ'd!hQ'f!iQ(r#sS+p%|%}Q+t&PQ,a&bQ,e&dS-m'g'hQ.j(sQ1S+qQ1Y+wQ1[+xQ1_+|Q1u,bS1y,f,gQ2|-nQ5e1UQ5i1XQ5n1aQ6Z1zQ8_5gQ8b5kQ8f5pQ:T8^R;T:U!U$zi$d%O%Q%^%_%c*S*U*b*x*y/Q/y0a0c0j0k0p4_5Q8V9p>O>W>X!^%yy!i!u%{%|%}'V'f'g'h'l'v*k+p+q-Z-m-n-u0S0V1S2u2|3T4r4s4v7}9{Q+j%wQ,V&[Q,Y&]Q,d&dQ.i(rQ1t,aU1x,e,f,gQ3e.jQ6U1uS6Y1y1zQ8x6Z#f>S#v$b$c$x${)z*W*Z*h+h+k,U,X.g/e/n/z/|1g1j1r3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k[>]o>TOS&[!Q&iQ&]!RQ&^!SU+O%[%d=rR,T&Y%]%Si#v$b$c$d$x${%O%Q%^%_%c)z*S*U*W*Z*b*h*x*y+h+k,U,X.g/Q/e/n/y/z/|0a0c0j0k0p1g1j1r3c4^4_4j4o5Q5[5_6S7W7v8Q8V8[8q9b9p9y:P:`:r;Q;[;d;kO>W>X>[>]T){$u)|V+Q%]R$g$^c#Y#e%q%s%u(T(Z(u(z)S)T)U)V)W)X)Y)Z)[)])_)a)c)h)r+f+z-[-y.O.T.V.t.w.{.}/O/P/c0q2n3O3V3k3p3q3r3s3t3u3v3w3x3y3z3{3|4P4Q4X5X5c6u6{7Q7a7b7k7l8k9X9]9g9m9n:o;W;`RQ'Y!eR2q-^!W!nQ!e!r!v!y!z$|'W'`'a'm'n'o*[*l*p*r*s-^-d-f-v0]0`1p5{5}R1m,]nqOXst!Z#d%m&r&t&u&w,u,z2]2`Q&y!^Q'w!xS(t#u<^Q+n%zQ,_&_Q,`&aQ-k'eQ-x'pS.s(y=OS0r+Y=YQ1Q+oQ1o,^Q2d,|Q2f,}Q2m-XQ2z-lQ2}-pS5Y0s=dQ5a1RS5d1T=eQ6t2oQ6x2{Q6}3SQ8]5bQ9Y6vQ9Z6yQ9^7OR:l9V$b$]c#Y#e%s%u(T(Z(u(z)S)T)U)V)W)X)Y)Z)[)])_)a)c)h)r+f+z-[-y.O.T.V.t.w.{/O/P/c0q2n3O3V3k3p3q3r3s3t3u3v3w3x3y3z3{3|4P4Q4X5X5c6u6{7Q7a7b7k7l8k9X9]9g9m9n:o;W;`RS#q]RU$fd)`,oS(q#p'jU*w%R(x4OU0n+P.o7gQ5^0yQ7V3`Q9d7YR:s9em!tQ!r!v!y!z'a'm'n'o-f-v1p5{5}Q'u!uS(g#g2VS-t'l'xQ/t*^Q0S*kQ3U-wQ4f/uQ4r0UQ4s0VQ4x0_Q7r4`S7}4t4vS8R4y4{Q9r7sQ9v7yQ9{8OQ:Q8TS:{9w9xS;g:|;PS;s;h;iS;{;t;uSRR=n>Q%^bOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&o&r&t&u&w&{'T'c's(U(W(^(e(y({)P*O*j+Y+_+i,r,u,z-j-r.Q.W.h.u.|/o0^0m0s1T1s2T2U2W2Y2]2`2b3Q3W3d3l4z6T6e6f6i6|7[8t9T9_Q%fj!^%xy!i!u%{%|%}'V'f'g'h'l'v*k+p+q-Z-m-n-u0S0V1S2u2|3T4r4s4v7}9{S&Oz!jQ+m%yQ,c&dW1w,d,e,f,gU6X1x1y1zS8w6Y6ZQ:d8x!p=i$Z$n'X)t-Y/W2p4T5w6s:Z:mRQ=s>PR=t>Q%QeOPXYstuvw!Z!`!g!o#S#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&r&t&u&w&{'T'c's(W(^(e(y({)P*O*j+Y+_+i,r,u,z-j-r.Q.W.h.u.|/o0^0m0s1T1s2T2U2W2Y2]2`2b3Q3W3d3l4z6T6e6f6i6|7[8t9T9_Y#bWZ#W#Z(U!b%jm#h#i#l$x%e%h(_(i(j(k*Z*_*c+[+^+`,q-W.U.[.].^.`/n/q2e3[3]4a6r7TQ,p&o!n=j$Z$n)t-Y/W2p4T5w6s:Z:mRR=m'XU'^!e%i*[R2s-aX'[!e%i*[-a%SdOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&r&t&u&w&{'T'c's(U(W(^(e(y({)P*O*j+Y+_,r,u,z-j-r.Q.W.u.|/o0^0m0s1T1s2T2U2W2Y2]2`2b3Q3W3l4z6T6e6f6i6|8t9T9_!p)`$Z$n'X)t-Y/W2p4T5w6s:Z:mRQ,o&oQ0y+iQ3`.hQ7Y3dR9e7[!b$Tc#Y%q(T(Z(u(z)[)])a)h+z-y.O.T.V.t.w/c0q3O3V3k3{5X5c6{7Q7a9]:oR)_)r-[.}2n3p4P4X6u7b7k7l8k9X9g9m9n;W;`=uQ>W>YR>X>Z'OkOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n%m%t&R&k&n&o&r&t&u&w&{'T'X'c's(U(W(^(e(y({)P)t*O*j+Y+_+i,r,u,z-Y-j-r.Q.W.h.u.|/W/o0^0m0s1T1s2T2U2W2Y2]2`2b2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mRS$oh$pR4U/V'VgOPWXYZhstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n$p%m%t&R&k&n&o&r&t&u&w&{'T'X'c's(U(W(^(e(y({)P)t*O*j+Y+_+i,r,u,z-Y-j-r.Q.W.h.u.|/V/W/o0^0m0s1T1s2T2U2W2Y2]2`2b2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mRT$kf$qQ$ifS)k$l)oR)w$qT$jf$qT)m$l)o'VhOPWXYZhstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$Z$_$a$e$n$p%m%t&R&k&n&o&r&t&u&w&{'T'X'c's(U(W(^(e(y({)P)t*O*j+Y+_+i,r,u,z-Y-j-r.Q.W.h.u.|/V/W/o0^0m0s1T1s2T2U2W2Y2]2`2b2p3Q3W3d3l4T4z5w6T6e6f6i6s6|7[8t9T9_:Z:mRT$oh$pQ$rhR)v$p%^jOPWXYZstuvw!Z!`!g!o#S#W#Z#d#o#u#x#{$O$P$Q$R$S$T$U$V$W$X$_$a$e%m%t&R&k&n&o&r&t&u&w&{'T'c's(U(W(^(e(y({)P*O*j+Y+_+i,r,u,z-j-r.Q.W.h.u.|/o0^0m0s1T1s2T2U2W2Y2]2`2b3Q3W3d3l4z6T6e6f6i6|7[8t9T9_!q>P$Z$n'X)t-Y/W2p4T5w6s:Z:mR#glOPXZst!Z!`!o#S#d#o#{$n%m&k&n&o&r&t&u&w&{'T'c)P)t*j+_+i,r,u,z-j.h/W/o0^0m1s2T2U2W2Y2]2`2b3d4T4z6T6e6f6i7[8t9T!U%Ri$d%O%Q%^%_%c*S*U*b*x*y/Q/y0a0c0j0k0p4_5Q8V9p>O>W>X#f(x#v$b$c$x${)z*W*Z*h+h+k,U,X.g/e/n/z/|1g1j1r3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k[>]Q+U%aQ/d*Po4OO>W>XQ*d$zU*m$|*[*pQ+V%bQ0X*n#f=p#v$b$c$x${)z*W*Z*h+h+k,U,X.g/e/n/z/|1g1j1r3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k[>]n=qSQ=w>TQ=x>UR=y>V!U%Ri$d%O%Q%^%_%c*S*U*b*x*y/Q/y0a0c0j0k0p4_5Q8V9p>O>W>X#f(x#v$b$c$x${)z*W*Z*h+h+k,U,X.g/e/n/z/|1g1j1r3c4^4j4o5[5_6S7W7v8Q8[8q9b9y:P:`:r;Q;[;d;k[>]o4OO>W>X>[>]Q,W&]Q1i,YQ5s1hR8h5tV*o$|*[*pU*o$|*[*pT5z1p5{S0Q*j/oQ4w0^T8S4z:]Q+l%xQ0W*mQ1P+mQ1v,cQ6W1wQ8v6XQ:c8wR;^:d!U%Oi$d%O%Q%^%_%c*S*U*b*x*y/Q/y0a0c0j0k0p4_5Q8V9p>O>W>Xx*S$v)f*T*v+W/w0e0f4R4g5R5S5W7p8U:R:x=o=|=}S0a*u0b#f[>]nY>Z`=S3}7c7f7j9h:t:w;yS=^.m3iT=_7e9k!U%Qi$d%O%Q%^%_%c*S*U*b*x*y/Q/y0a0c0j0k0p4_5Q8V9p>O>W>X|*U$v)f*V*u+W/h/w0e0f4R4g4|5R5S5W7p8U:R:x=o=|=}S0c*v0d#f[>]nY>Zd=U3}7d7e7j9h9i:t:u:w;yS=`.n3jT=a7f9lrnOXst!V!Z#d%m&i&r&t&u&w,u,z2]2`Q&f!UR,r&ornOXst!V!Z#d%m&i&r&t&u&w,u,z2]2`R&f!UQ,[&^R1e,TsnOXst!V!Z#d%m&i&r&t&u&w,u,z2]2`Q1q,aS6R1t1uU8p6P6Q6US:_8r8sS;Y:^:aQ;m;ZR;w;nQ&m!VR,k&iR6_1}R:f8yW&Q|&V&W,QR1[+xQ&r!WR,u&sR,{&xT2^,z2`R-P&yQ-O&yR2g-PQ'z!{R-z'zSsOtQ#dXT%ps#dQ#OTR'|#OQ#RUR(O#RQ)|$uR/a)|Q#UVR(R#UQ#XWU(X#X(Y.RQ(Y#YR.R(ZQ-_'YR2r-_Q.v(zS3m.v3nR3n.wQ-f'aR2v-fY!rQ'a-f1p5{R'k!rQ/R)fR4S/RU#_W%h*ZU(`#_(a.SQ(a#`R.S([Q-b'^R2t-bt`OXst!V!Z#d%m&i&k&r&t&u&w,u,z2]2`S#hZ%eU#r`#h.]R.](kQ(l#jQ.Y(hW.b(l.Y3X7RQ3X.ZR7R3YQ)o$lR/X)oQ$phR)u$pQ$`cU)b$`-}Y>ZQ/{*fU4k/{4m7xQ4m/}R7x4lS*p$|*[R0Z*px*T$v)f*u*v+W/w0e0f4R4g5R5S5W7p8U:R:x=o=|=}!d.k(v)d*]*f.m.n.r/`/l/}0w1f3h4[4h4l5r7]7`7w7z8X8Z9t9|:S:};R;e;j;v>Y>ZU/i*T.k7ca7c3}7e7f7j9h:t:w;yQ0b*uQ3i.mU4}0b3i9kR9k7e|*V$v)f*u*v+W/h/w0e0f4R4g4|5R5S5W7p8U:R:x=o=|=}!h.l(v)d*]*f.m.n.r/`/l/}0w1f3f3h4[4h4l5r7]7^7`7w7z8X8Z9t9|:S:};R;e;j;v>Y>ZU/k*V.l7de7d3}7e7f7j9h9i:t:u:w;yQ0d*vQ3j.nU5P0d3j9lR9l7fQ*{%UR0h*{Q5]0wR8Y5]Q+a%kR0v+aQ5v1kS8j5v:[R:[8kQ,^&_R1n,^Q5{1pR8m5{Q1|,hS6]1|8zR8z6_Q1V+tW5h1V5j8a:VQ5j1YQ8a5iR:V8bQ+y&QR1]+yQ2`,zR6m2`YrOXst#dQ&v!ZQ+c%mQ,t&rQ,v&tQ,w&uQ,y&wQ2Z,uS2^,z2`R6l2]Q%opQ&z!_Q&}!aQ'P!bQ'R!cQ'r!uQ+b%lQ+n%zQ,S&XQ,j&mQ-R&|W-q'l't'u'xQ-x'pQ0Y*oQ1Q+oQ1d,RS2P,k,nQ2h-QQ2i-TQ2j-UQ2}-pW3P-s-t-w-yQ5a1RQ5m1`Q5q1fQ6V1vQ6a2RQ6k2[U6z3O3R3UQ6}3SQ8]5bQ8e5oQ8g5rQ8l5zQ8u6WQ8{6`S9[6{7PQ9^7OQ:W8cQ:b8vQ:g8|Q:n9]Q;U:XQ;]:cQ;a:oQ;l;VR;o;^Q%zyQ'e!iQ'p!uU+o%{%|%}Q-X'VU-l'f'g'hS-p'l'vQ0R*kS1R+p+qQ2o-ZS2{-m-nQ3S-uS4p0S0VQ5b1SQ6v2uQ6y2|Q7O3TU7{4r4s4vQ9z7}R;O9{S$wi>OR*|%VU%Ui%V>OR0g*zQ$viS(v#v+kS)d$b$cQ)f$dQ*]$xS*f${*ZQ*u%OQ*v%QQ+R%^Q+S%_Q+W%cQ.mOQ=|>WQ=}>XQ>Y>[R>Z>]Q+P%]Q.oRR#[WR'Z!el!tQ!r!v!y!z'a'm'n'o-f-v1p5{5}S'V!e-^U*k$|*[*pS-Z'W'`S0V*l*rQ0_*sQ2u-dQ4v0]R4{0`R(|#xQ!fQT-e'a-f]!qQ!r'a-f1p5{Q#p]R'j < TypeParamList in out const TypeDefinition extends ThisType this LiteralType ArithOp Number BooleanLiteral TemplateType InterpolationEnd Interpolation InterpolationStart NullType null VoidType void TypeofType typeof MemberExpression . PropertyName [ TemplateString Escape Interpolation super RegExp ] ArrayExpression Spread , } { ObjectExpression Property async get set PropertyDefinition Block : NewTarget new NewExpression ) ( ArgList UnaryExpression delete LogicOp BitOp YieldExpression yield AwaitExpression await ParenthesizedExpression ClassExpression class ClassBody MethodDeclaration Decorator @ MemberExpression PrivatePropertyName CallExpression TypeArgList CompareOp < declare Privacy static abstract override PrivatePropertyDefinition PropertyDeclaration readonly accessor Optional TypeAnnotation Equals StaticBlock FunctionExpression ArrowFunction ParamList ParamList ArrayPattern ObjectPattern PatternProperty VariableDefinition Privacy readonly Arrow MemberExpression BinaryExpression ArithOp ArithOp ArithOp ArithOp BitOp CompareOp instanceof satisfies CompareOp BitOp BitOp BitOp LogicOp LogicOp ConditionalExpression LogicOp LogicOp AssignmentExpression UpdateOp PostfixExpression CallExpression InstantiationExpression TaggedTemplateExpression DynamicImport import ImportMeta JSXElement JSXSelfCloseEndTag JSXSelfClosingTag JSXIdentifier JSXBuiltin JSXIdentifier JSXNamespacedName JSXMemberExpression JSXSpreadAttribute JSXAttribute JSXAttributeValue JSXEscape JSXEndTag JSXOpenTag JSXFragmentTag JSXText JSXEscape JSXStartCloseTag JSXCloseTag PrefixCast < ArrowFunction TypeParamList SequenceExpression InstantiationExpression KeyofType keyof UniqueType unique ImportType InferredType infer TypeName ParenthesizedType FunctionSignature ParamList NewSignature IndexedType TupleType Label ArrayType ReadonlyType ObjectType MethodType PropertyType IndexSignature PropertyDefinition CallSignature TypePredicate asserts is NewSignature new UnionType LogicOp IntersectionType LogicOp ConditionalType ParameterizedType ClassDeclaration abstract implements type VariableDeclaration let var using TypeAliasDeclaration InterfaceDeclaration interface EnumDeclaration enum EnumBody NamespaceDeclaration namespace module AmbientDeclaration declare GlobalDeclaration global ClassDeclaration ClassBody AmbientFunctionDeclaration ExportGroup VariableName VariableName ImportDeclaration defer ImportGroup ForStatement for ForSpec ForInSpec ForOfSpec of WhileStatement while WithStatement with DoStatement do IfStatement if else SwitchStatement switch SwitchBody CaseLabel case DefaultLabel TryStatement try CatchClause catch FinallyClause finally ReturnStatement return ThrowStatement throw BreakStatement break ContinueStatement continue DebuggerStatement debugger LabeledStatement ExpressionStatement SingleExpression SingleClassItem",maxTerm:381,context:u5,nodeProps:[["isolate",-8,5,6,14,37,39,51,53,55,""],["group",-26,9,17,19,68,208,212,216,217,219,222,225,235,238,244,246,248,250,253,259,265,267,269,271,273,275,276,"Statement",-34,13,14,32,35,36,42,51,54,55,57,62,70,72,76,80,82,84,85,110,111,121,122,137,140,142,143,144,145,146,148,149,168,170,172,"Expression",-23,31,33,37,41,43,45,174,176,178,179,181,182,183,185,186,187,189,190,191,202,204,206,207,"Type",-3,88,103,109,"ClassItem"],["openedBy",23,"<",38,"InterpolationStart",56,"[",60,"{",73,"(",161,"JSXStartCloseTag"],["closedBy",-2,24,169,">",40,"InterpolationEnd",50,"]",61,"}",74,")",166,"JSXEndTag"]],propSources:[b5],skippedNodes:[0,5,6,279],repeatNodeCount:37,tokenData:"$Fq07[R!bOX%ZXY+gYZ-yZ[+g[]%Z]^.c^p%Zpq+gqr/mrs3cst:_tuEruvJSvwLkwx! Yxy!'iyz!(sz{!)}{|!,q|}!.O}!O!,q!O!P!/Y!P!Q!9j!Q!R#:O!R![#<_![!]#I_!]!^#Jk!^!_#Ku!_!`$![!`!a$$v!a!b$*T!b!c$,r!c!}Er!}#O$-|#O#P$/W#P#Q$4o#Q#R$5y#R#SEr#S#T$7W#T#o$8b#o#p$x#r#s$@U#s$f%Z$f$g+g$g#BYEr#BY#BZ$A`#BZ$ISEr$IS$I_$A`$I_$I|Er$I|$I}$Dk$I}$JO$Dk$JO$JTEr$JT$JU$A`$JU$KVEr$KV$KW$A`$KW&FUEr&FU&FV$A`&FV;'SEr;'S;=`I|<%l?HTEr?HT?HU$A`?HUOEr(n%d_$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z&j&hT$j&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c&j&zP;=`<%l&c'|'U]$j&j([!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!b(SU([!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}!b(iP;=`<%l'}'|(oP;=`<%l&}'[(y]$j&j(XpOY(rYZ&cZr(rrs&cs!^(r!^!_)r!_#O(r#O#P&c#P#o(r#o#p)r#p;'S(r;'S;=`*a<%lO(rp)wU(XpOY)rZr)rs#O)r#P;'S)r;'S;=`*Z<%lO)rp*^P;=`<%l)r'[*dP;=`<%l(r#S*nX(Xp([!bOY*gZr*grs'}sw*gwx)rx#O*g#P;'S*g;'S;=`+Z<%lO*g#S+^P;=`<%l*g(n+dP;=`<%l%Z07[+rq$j&j(Xp([!b'}0/lOX%ZXY+gYZ&cZ[+g[p%Zpq+gqr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p$f%Z$f$g+g$g#BY%Z#BY#BZ+g#BZ$IS%Z$IS$I_+g$I_$JT%Z$JT$JU+g$JU$KV%Z$KV$KW+g$KW&FU%Z&FU&FV+g&FV;'S%Z;'S;=`+a<%l?HT%Z?HT?HU+g?HUO%Z07[.ST(Y#S$j&j(O0/lO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c07[.n_$j&j(Xp([!b(O0/lOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z)3p/x`$j&j!p),Q(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`0z!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z(KW1V`#w(Ch$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`2X!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z(KW2d_#w(Ch$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'At3l_(W':f$j&j([!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k(^4r_$j&j([!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k&z5vX$j&jOr5qrs6cs!^5q!^!_6y!_#o5q#o#p6y#p;'S5q;'S;=`7h<%lO5q&z6jT$e`$j&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c`6|TOr6yrs7]s;'S6y;'S;=`7b<%lO6y`7bO$e``7eP;=`<%l6y&z7kP;=`<%l5q(^7w]$e`$j&j([!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!r8uZ([!bOY8pYZ6yZr8prs9hsw8pwx6yx#O8p#O#P6y#P;'S8p;'S;=`:R<%lO8p!r9oU$e`([!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}!r:UP;=`<%l8p(^:[P;=`<%l4k%9[:hh$j&j(Xp([!bOY%ZYZ&cZq%Zqr`#P#o`x!^=^!^!_?q!_#O=^#O#P>`#P#o=^#o#p?q#p;'S=^;'S;=`@h<%lO=^&n>gXWS$j&jOY>`YZ&cZ!^>`!^!_?S!_#o>`#o#p?S#p;'S>`;'S;=`?k<%lO>`S?XSWSOY?SZ;'S?S;'S;=`?e<%lO?SS?hP;=`<%l?S&n?nP;=`<%l>`!f?xWWS([!bOY?qZw?qwx?Sx#O?q#O#P?S#P;'S?q;'S;=`@b<%lO?q!f@eP;=`<%l?q(Q@kP;=`<%l=^'`@w]WS$j&j(XpOY@nYZ&cZr@nrs>`s!^@n!^!_Ap!_#O@n#O#P>`#P#o@n#o#pAp#p;'S@n;'S;=`Bg<%lO@ntAwWWS(XpOYApZrAprs?Ss#OAp#O#P?S#P;'SAp;'S;=`Ba<%lOAptBdP;=`<%lAp'`BjP;=`<%l@n#WBvYWS(Xp([!bOYBmZrBmrs?qswBmwxApx#OBm#O#P?S#P;'SBm;'S;=`Cf<%lOBm#WCiP;=`<%lBm(rCoP;=`<%l^!Q^$j&j!X7`OY!=yYZ&cZ!P!=y!P!Q!>|!Q!^!=y!^!_!@c!_!}!=y!}#O!CW#O#P!Dy#P#o!=y#o#p!@c#p;'S!=y;'S;=`!Ek<%lO!=y|#X#Z&c#Z#[!>|#[#]&c#]#^!>|#^#a&c#a#b!>|#b#g&c#g#h!>|#h#i&c#i#j!>|#j#k!>|#k#m&c#m#n!>|#n#o&c#p;'S&c;'S;=`&w<%lO&c7`!@hX!X7`OY!@cZ!P!@c!P!Q!AT!Q!}!@c!}#O!Ar#O#P!Bq#P;'S!@c;'S;=`!CQ<%lO!@c7`!AYW!X7`#W#X!AT#Z#[!AT#]#^!AT#a#b!AT#g#h!AT#i#j!AT#j#k!AT#m#n!AT7`!AuVOY!ArZ#O!Ar#O#P!B[#P#Q!@c#Q;'S!Ar;'S;=`!Bk<%lO!Ar7`!B_SOY!ArZ;'S!Ar;'S;=`!Bk<%lO!Ar7`!BnP;=`<%l!Ar7`!BtSOY!@cZ;'S!@c;'S;=`!CQ<%lO!@c7`!CTP;=`<%l!@c^!Ezl$j&j([!b!X7`OY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#W&}#W#X!Eq#X#Z&}#Z#[!Eq#[#]&}#]#^!Eq#^#a&}#a#b!Eq#b#g&}#g#h!Eq#h#i&}#i#j!Eq#j#k!Eq#k#m&}#m#n!Eq#n#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}8r!GyZ([!b!X7`OY!GrZw!Grwx!@cx!P!Gr!P!Q!Hl!Q!}!Gr!}#O!JU#O#P!Bq#P;'S!Gr;'S;=`!J|<%lO!Gr8r!Hse([!b!X7`OY'}Zw'}x#O'}#P#W'}#W#X!Hl#X#Z'}#Z#[!Hl#[#]'}#]#^!Hl#^#a'}#a#b!Hl#b#g'}#g#h!Hl#h#i'}#i#j!Hl#j#k!Hl#k#m'}#m#n!Hl#n;'S'};'S;=`(f<%lO'}8r!JZX([!bOY!JUZw!JUwx!Arx#O!JU#O#P!B[#P#Q!Gr#Q;'S!JU;'S;=`!Jv<%lO!JU8r!JyP;=`<%l!JU8r!KPP;=`<%l!Gr>^!KZ^$j&j([!bOY!KSYZ&cZw!KSwx!CWx!^!KS!^!_!JU!_#O!KS#O#P!DR#P#Q!^!LYP;=`<%l!KS>^!L`P;=`<%l!_#c#d#Bq#d#l%Z#l#m#Es#m#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#>j_$j&j(Xp([!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#?rd$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#AQ!R!S#AQ!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#AQ#S#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#A]f$j&j(Xp([!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#AQ!R!S#AQ!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#AQ#S#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Bzc$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!Y#DV!Y!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#DV#S#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Dbe$j&j(Xp([!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!Y#DV!Y!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#DV#S#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#E|g$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q![#Ge![!^%Z!^!_*g!_!c%Z!c!i#Ge!i#O%Z#O#P&c#P#R%Z#R#S#Ge#S#T%Z#T#Z#Ge#Z#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z'Ad#Gpi$j&j(Xp([!bs'9tOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q![#Ge![!^%Z!^!_*g!_!c%Z!c!i#Ge!i#O%Z#O#P&c#P#R%Z#R#S#Ge#S#T%Z#T#Z#Ge#Z#b%Z#b#c#>_#c#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z*)x#Il_!g$b$j&j$P)Lv(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z)[#Jv_al$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z04f#LS^h#)`#R-v$?V_!^(CdvBr$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z?O$@a_!q7`$j&j(Xp([!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z07[$Aq|$j&j(Xp([!b'}0/l$^#t(U,2j(f$I[OX%ZXY+gYZ&cZ[+g[p%Zpq+gqr%Zrs&}st%ZtuEruw%Zwx(rx}%Z}!OGv!O!Q%Z!Q![Er![!^%Z!^!_*g!_!c%Z!c!}Er!}#O%Z#O#P&c#P#R%Z#R#SEr#S#T%Z#T#oEr#o#p*g#p$f%Z$f$g+g$g#BYEr#BY#BZ$A`#BZ$ISEr$IS$I_$A`$I_$JTEr$JT$JU$A`$JU$KVEr$KV$KW$A`$KW&FUEr&FU&FV$A`&FV;'SEr;'S;=`I|<%l?HTEr?HT?HU$A`?HUOEr07[$D|k$j&j(Xp([!b(O0/l$^#t(U,2j(f$I[OY%ZYZ&cZr%Zrs&}st%ZtuEruw%Zwx(rx}%Z}!OGv!O!Q%Z!Q![Er![!^%Z!^!_*g!_!c%Z!c!}Er!}#O%Z#O#P&c#P#R%Z#R#SEr#S#T%Z#T#oEr#o#p*g#p$g%Z$g;'SEr;'S;=`I|<%lOEr",tokenizers:[p5,O5,g5,m5,2,3,4,5,6,7,8,9,10,11,12,13,14,d5,new ln("$S~RRtu[#O#Pg#S#T#|~_P#o#pb~gOx~~jVO#i!P#i#j!U#j#l!P#l#m!q#m;'S!P;'S;=`#v<%lO!P~!UO!U~~!XS!Q![!e!c!i!e#T#Z!e#o#p#Z~!hR!Q![!q!c!i!q#T#Z!q~!tR!Q![!}!c!i!}#T#Z!}~#QR!Q![!P!c!i!P#T#Z!P~#^R!Q![#g!c!i#g#T#Z#g~#jS!Q![#g!c!i#g#T#Z#g#q#r!P~#yP;=`<%l!P~$RO(d~~",141,341),new ln("j~RQYZXz{^~^O(R~~aP!P!Qd~iO(S~~",25,324)],topRules:{Script:[0,7],SingleExpression:[1,277],SingleClassItem:[2,278]},dialects:{jsx:0,ts:15152},dynamicPrecedences:{80:1,82:1,94:1,170:1,200:1},specialized:[{term:328,get:i=>y5[i]||-1},{term:344,get:i=>S5[i]||-1},{term:95,get:i=>x5[i]||-1}],tokenPrec:15178});var Vx=[it("function ${name}(${params}) {\n ${}\n}",{label:"function",detail:"definition",type:"keyword"}),it("for (let ${index} = 0; ${index} < ${bound}; ${index}++) {\n ${}\n}",{label:"for",detail:"loop",type:"keyword"}),it("for (let ${name} of ${collection}) {\n ${}\n}",{label:"for",detail:"of loop",type:"keyword"}),it("do {\n ${}\n} while (${})",{label:"do",detail:"loop",type:"keyword"}),it("while (${}) {\n ${}\n}",{label:"while",detail:"loop",type:"keyword"}),it(`try { + \${} +} catch (\${error}) { + \${} +}`,{label:"try",detail:"/ catch block",type:"keyword"}),it("if (${}) {\n ${}\n}",{label:"if",detail:"block",type:"keyword"}),it(`if (\${}) { + \${} +} else { + \${} +}`,{label:"if",detail:"/ else block",type:"keyword"}),it(`class \${name} { + constructor(\${params}) { + \${} + } +}`,{label:"class",detail:"definition",type:"keyword"}),it('import {${names}} from "${module}"\n${}',{label:"import",detail:"named",type:"keyword"}),it('import ${name} from "${module}"\n${}',{label:"import",detail:"default",type:"keyword"})],w5=Vx.concat([it("interface ${name} {\n ${}\n}",{label:"interface",detail:"definition",type:"keyword"}),it("type ${name} = ${type}",{label:"type",detail:"definition",type:"keyword"}),it("enum ${name} {\n ${}\n}",{label:"enum",detail:"definition",type:"keyword"})]),Ix=new rs,Yx=new Set(["Script","Block","FunctionExpression","FunctionDeclaration","ArrowFunction","MethodDeclaration","ForStatement"]);function el(i){return(e,t)=>{let n=e.node.getChild("VariableDefinition");return n&&t(n,i),!0}}var k5=["FunctionDeclaration"],Q5={FunctionDeclaration:el("function"),ClassDeclaration:el("class"),ClassExpression:()=>!0,EnumDeclaration:el("constant"),TypeAliasDeclaration:el("type"),NamespaceDeclaration:el("namespace"),VariableDefinition(i,e){i.matchContext(k5)||e(i,"variable")},TypeDefinition(i,e){e(i,"type")},__proto__:null};function zx(i,e){let t=Ix.get(e);if(t)return t;let n=[],r=!0;function s(o,l){let a=i.sliceString(o.from,o.to);n.push({label:a,type:l})}return e.cursor(F.IncludeAnonymous).iterate(o=>{if(r)r=!1;else if(o.name){let l=Q5[o.name];if(l&&l(o,s)||Yx.has(o.name))return!1}else if(o.to-o.from>8192){for(let l of zx(i,o.node))n.push(l);return!1}}),Ix.set(e,n),n}var Ux=/^[\w$\xa1-\uffff][\w$\d\xa1-\uffff]*$/,Wx=["TemplateString","String","RegExp","LineComment","BlockComment","VariableDefinition","TypeDefinition","Label","PropertyDefinition","PropertyName","PrivatePropertyDefinition","PrivatePropertyName","JSXText","JSXAttributeValue","JSXOpenTag","JSXCloseTag","JSXSelfClosingTag",".","?."];function v5(i){let e=ee(i.state).resolveInner(i.pos,-1);if(Wx.indexOf(e.name)>-1)return null;let t=e.name=="VariableName"||e.to-e.from<20&&Ux.test(i.state.sliceDoc(e.from,e.to));if(!t&&!i.explicit)return null;let n=[];for(let r=e;r;r=r.parent)Yx.has(r.name)&&(n=n.concat(zx(i.state.doc,r)));return{options:n,from:t?e.from:i.pos,validFor:Ux}}var qt=Hi.define({name:"javascript",parser:jx.configure({props:[$i.add({IfStatement:os({except:/^\s*({|else\b)/}),TryStatement:os({except:/^\s*({|catch\b|finally\b)/}),LabeledStatement:Pb,SwitchBody:i=>{let e=i.textAfter,t=/^\s*\}/.test(e),n=/^\s*(case|default)\b/.test(e);return i.baseIndent+(t?0:n?1:2)*i.unit},Block:vb({closing:"}"}),ArrowFunction:i=>i.baseIndent+i.unit,"TemplateString BlockComment":()=>null,"Statement Property":os({except:/^\s*{/}),JSXElement(i){let e=/^\s*<\//.test(i.textAfter);return i.lineIndent(i.node.from)+(e?0:i.unit)},JSXEscape(i){let e=/\s*\}/.test(i.textAfter);return i.lineIndent(i.node.from)+(e?0:i.unit)},"JSXOpenTag JSXSelfClosingTag"(i){return i.column(i.node.from)+i.unit}}),fi.add({"Block ClassBody SwitchBody EnumBody ObjectExpression ArrayExpression ObjectType":Sc,BlockComment(i){return{from:i.from+2,to:i.to-2}},JSXElement(i){let e=i.firstChild;if(!e||e.name=="JSXSelfClosingTag")return null;let t=i.lastChild;return{from:e.to,to:t.type.isError?i.to:t.from}},"JSXSelfClosingTag JSXOpenTag"(i){var e;let t=(e=i.firstChild)===null||e===void 0?void 0:e.nextSibling,n=i.lastChild;return!t||t.type.isError?null:{from:t.to,to:n.type.isError?i.to:n.from}}})]}),languageData:{closeBrackets:{brackets:["(","[","{","'",'"',"`"]},commentTokens:{line:"//",block:{open:"/*",close:"*/"}},indentOnInput:/^\s*(?:case |default:|\{|\}|<\/)$/,wordChars:"$"}}),Nx={test:i=>/^JSX/.test(i.name),facet:Mo({commentTokens:{block:{open:"{/*",close:"*/}"}}})},aO=qt.configure({dialect:"ts"},"typescript"),cO=qt.configure({dialect:"jsx",props:[bc.add(i=>i.isTop?[Nx]:void 0)]}),hO=qt.configure({dialect:"jsx ts",props:[bc.add(i=>i.isTop?[Nx]:void 0)]},"typescript"),qx=i=>({label:i,type:"keyword"}),Gx="break case const continue default delete export extends false finally in instanceof let new return static super switch this throw true typeof var yield".split(" ").map(qx),$5=Gx.concat(["declare","implements","private","protected","public"].map(qx));function Fx(i={}){let e=i.jsx?i.typescript?hO:cO:i.typescript?aO:qt,t=i.typescript?w5.concat($5):Vx.concat(Gx);return new Yt(e,[qt.data.of({autocomplete:Hy(Wx,up(t))}),qt.data.of({autocomplete:v5}),i.jsx?T5:[]])}function P5(i){for(;;){if(i.name=="JSXOpenTag"||i.name=="JSXSelfClosingTag"||i.name=="JSXFragmentTag")return i;if(i.name=="JSXEscape"||!i.parent)return null;i=i.parent}}function Bx(i,e,t=i.length){for(let n=e?.firstChild;n;n=n.nextSibling)if(n.name=="JSXIdentifier"||n.name=="JSXBuiltin"||n.name=="JSXNamespacedName"||n.name=="JSXMemberExpression")return i.sliceString(n.from,Math.min(n.to,t));return""}var C5=typeof navigator=="object"&&/Android\b/.test(navigator.userAgent),T5=M.inputHandler.of((i,e,t,n,r)=>{if((C5?i.composing:i.compositionStarted)||i.state.readOnly||e!=t||n!=">"&&n!="/"||!qt.isActiveAt(i.state,e,-1))return!1;let s=r(),{state:o}=s,l=o.changeByRange(a=>{var c;let{head:h}=a,f=ee(o).resolveInner(h-1,-1),u;if(f.name=="JSXStartTag"&&(f=f.parent),!(o.doc.sliceString(h-1,h)!=n||f.name=="JSXAttributeValue"&&f.to>h)){if(n==">"&&f.name=="JSXFragmentTag")return{range:a,changes:{from:h,insert:""}};if(n=="/"&&f.name=="JSXStartCloseTag"){let d=f.parent,p=d.parent;if(p&&d.from==h-2&&((u=Bx(o.doc,p.firstChild,h))||((c=p.firstChild)===null||c===void 0?void 0:c.name)=="JSXFragmentTag")){let O=`${u}>`;return{range:v.cursor(h+O.length,-1),changes:{from:h,insert:O}}}}else if(n==">"){let d=P5(f);if(d&&d.name=="JSXOpenTag"&&!/^\/?>|^<\//.test(o.doc.sliceString(h,h+2))&&(u=Bx(o.doc,d,h)))return{range:a,changes:{from:h,insert:``}}}}return{range:a}});return l.changes.empty?!1:(i.dispatch([s,o.update(l,{userEvent:"input.complete",scrollIntoView:!0})]),!0)});var tl=["_blank","_self","_top","_parent"],fO=["ascii","utf-8","utf-16","latin1","latin1"],uO=["get","post","put","delete"],dO=["application/x-www-form-urlencoded","multipart/form-data","text/plain"],Qt=["true","false"],I={},A5={a:{attrs:{href:null,ping:null,type:null,media:null,target:tl,hreflang:null}},abbr:I,address:I,area:{attrs:{alt:null,coords:null,href:null,target:null,ping:null,media:null,hreflang:null,type:null,shape:["default","rect","circle","poly"]}},article:I,aside:I,audio:{attrs:{src:null,mediagroup:null,crossorigin:["anonymous","use-credentials"],preload:["none","metadata","auto"],autoplay:["autoplay"],loop:["loop"],controls:["controls"]}},b:I,base:{attrs:{href:null,target:tl}},bdi:I,bdo:I,blockquote:{attrs:{cite:null}},body:I,br:I,button:{attrs:{form:null,formaction:null,name:null,value:null,autofocus:["autofocus"],disabled:["autofocus"],formenctype:dO,formmethod:uO,formnovalidate:["novalidate"],formtarget:tl,type:["submit","reset","button"]}},canvas:{attrs:{width:null,height:null}},caption:I,center:I,cite:I,code:I,col:{attrs:{span:null}},colgroup:{attrs:{span:null}},command:{attrs:{type:["command","checkbox","radio"],label:null,icon:null,radiogroup:null,command:null,title:null,disabled:["disabled"],checked:["checked"]}},data:{attrs:{value:null}},datagrid:{attrs:{disabled:["disabled"],multiple:["multiple"]}},datalist:{attrs:{data:null}},dd:I,del:{attrs:{cite:null,datetime:null}},details:{attrs:{open:["open"]}},dfn:I,div:I,dl:I,dt:I,em:I,embed:{attrs:{src:null,type:null,width:null,height:null}},eventsource:{attrs:{src:null}},fieldset:{attrs:{disabled:["disabled"],form:null,name:null}},figcaption:I,figure:I,footer:I,form:{attrs:{action:null,name:null,"accept-charset":fO,autocomplete:["on","off"],enctype:dO,method:uO,novalidate:["novalidate"],target:tl}},h1:I,h2:I,h3:I,h4:I,h5:I,h6:I,head:{children:["title","base","link","style","meta","script","noscript","command"]},header:I,hgroup:I,hr:I,html:{attrs:{manifest:null}},i:I,iframe:{attrs:{src:null,srcdoc:null,name:null,width:null,height:null,sandbox:["allow-top-navigation","allow-same-origin","allow-forms","allow-scripts"],seamless:["seamless"]}},img:{attrs:{alt:null,src:null,ismap:null,usemap:null,width:null,height:null,crossorigin:["anonymous","use-credentials"]}},input:{attrs:{alt:null,dirname:null,form:null,formaction:null,height:null,list:null,max:null,maxlength:null,min:null,name:null,pattern:null,placeholder:null,size:null,src:null,step:null,value:null,width:null,accept:["audio/*","video/*","image/*"],autocomplete:["on","off"],autofocus:["autofocus"],checked:["checked"],disabled:["disabled"],formenctype:dO,formmethod:uO,formnovalidate:["novalidate"],formtarget:tl,multiple:["multiple"],readonly:["readonly"],required:["required"],type:["hidden","text","search","tel","url","email","password","datetime","date","month","week","time","datetime-local","number","range","color","checkbox","radio","file","submit","image","reset","button"]}},ins:{attrs:{cite:null,datetime:null}},kbd:I,keygen:{attrs:{challenge:null,form:null,name:null,autofocus:["autofocus"],disabled:["disabled"],keytype:["RSA"]}},label:{attrs:{for:null,form:null}},legend:I,li:{attrs:{value:null}},link:{attrs:{href:null,type:null,hreflang:null,media:null,sizes:["all","16x16","16x16 32x32","16x16 32x32 64x64"]}},map:{attrs:{name:null}},mark:I,menu:{attrs:{label:null,type:["list","context","toolbar"]}},meta:{attrs:{content:null,charset:fO,name:["viewport","application-name","author","description","generator","keywords"],"http-equiv":["content-language","content-type","default-style","refresh"]}},meter:{attrs:{value:null,min:null,low:null,high:null,max:null,optimum:null}},nav:I,noscript:I,object:{attrs:{data:null,type:null,name:null,usemap:null,form:null,width:null,height:null,typemustmatch:["typemustmatch"]}},ol:{attrs:{reversed:["reversed"],start:null,type:["1","a","A","i","I"]},children:["li","script","template","ul","ol"]},optgroup:{attrs:{disabled:["disabled"],label:null}},option:{attrs:{disabled:["disabled"],label:null,selected:["selected"],value:null}},output:{attrs:{for:null,form:null,name:null}},p:I,param:{attrs:{name:null,value:null}},pre:I,progress:{attrs:{value:null,max:null}},q:{attrs:{cite:null}},rp:I,rt:I,ruby:I,samp:I,script:{attrs:{type:["text/javascript"],src:null,async:["async"],defer:["defer"],charset:fO}},section:I,select:{attrs:{form:null,name:null,size:null,autofocus:["autofocus"],disabled:["disabled"],multiple:["multiple"]}},slot:{attrs:{name:null}},small:I,source:{attrs:{src:null,type:null,media:null}},span:I,strong:I,style:{attrs:{type:["text/css"],media:null,scoped:null}},sub:I,summary:I,sup:I,table:I,tbody:I,td:{attrs:{colspan:null,rowspan:null,headers:null}},template:I,textarea:{attrs:{dirname:null,form:null,maxlength:null,name:null,placeholder:null,rows:null,cols:null,autofocus:["autofocus"],disabled:["disabled"],readonly:["readonly"],required:["required"],wrap:["soft","hard"]}},tfoot:I,th:{attrs:{colspan:null,rowspan:null,headers:null,scope:["row","col","rowgroup","colgroup"]}},thead:I,time:{attrs:{datetime:null}},title:I,tr:I,track:{attrs:{src:null,label:null,default:null,kind:["subtitles","captions","descriptions","chapters","metadata"],srclang:null}},ul:{children:["li","script","template","ul","ol"]},var:I,video:{attrs:{src:null,poster:null,width:null,height:null,crossorigin:["anonymous","use-credentials"],preload:["auto","metadata","none"],autoplay:["autoplay"],mediagroup:["movie"],muted:["muted"],controls:["controls"]}},wbr:I},ew={accesskey:null,class:null,contenteditable:Qt,contextmenu:null,dir:["ltr","rtl","auto"],draggable:["true","false","auto"],dropzone:["copy","move","link","string:","file:"],hidden:["hidden"],id:null,inert:["inert"],itemid:null,itemprop:null,itemref:null,itemscope:["itemscope"],itemtype:null,lang:["ar","bn","de","en-GB","en-US","es","fr","hi","id","ja","pa","pt","ru","tr","zh"],spellcheck:Qt,autocorrect:Qt,autocapitalize:Qt,style:null,tabindex:null,title:null,translate:["yes","no"],rel:["stylesheet","alternate","author","bookmark","help","license","next","nofollow","noreferrer","prefetch","prev","search","tag"],role:"alert application article banner button cell checkbox complementary contentinfo dialog document feed figure form grid gridcell heading img list listbox listitem main navigation region row rowgroup search switch tab table tabpanel textbox timer".split(" "),"aria-activedescendant":null,"aria-atomic":Qt,"aria-autocomplete":["inline","list","both","none"],"aria-busy":Qt,"aria-checked":["true","false","mixed","undefined"],"aria-controls":null,"aria-describedby":null,"aria-disabled":Qt,"aria-dropeffect":null,"aria-expanded":["true","false","undefined"],"aria-flowto":null,"aria-grabbed":["true","false","undefined"],"aria-haspopup":Qt,"aria-hidden":Qt,"aria-invalid":["true","false","grammar","spelling"],"aria-label":null,"aria-labelledby":null,"aria-level":null,"aria-live":["off","polite","assertive"],"aria-multiline":Qt,"aria-multiselectable":Qt,"aria-owns":null,"aria-posinset":null,"aria-pressed":["true","false","mixed","undefined"],"aria-readonly":Qt,"aria-relevant":null,"aria-required":Qt,"aria-selected":["true","false","undefined"],"aria-setsize":null,"aria-sort":["ascending","descending","none","other"],"aria-valuemax":null,"aria-valuemin":null,"aria-valuenow":null,"aria-valuetext":null},tw="beforeunload copy cut dragstart dragover dragleave dragenter dragend drag paste focus blur change click load mousedown mouseenter mouseleave mouseup keydown keyup resize scroll unload".split(" ").map(i=>"on"+i);for(let i of tw)ew[i]=null;var ir=class{constructor(e,t){this.tags={...A5,...e},this.globalAttrs={...ew,...t},this.allTags=Object.keys(this.tags),this.globalAttrNames=Object.keys(this.globalAttrs)}};ir.default=new ir;function nr(i,e,t=i.length){if(!e)return"";let n=e.firstChild,r=n&&n.getChild("TagName");return r?i.sliceString(r.from,Math.min(r.to,t)):""}function gs(i,e=!1){for(;i;i=i.parent)if(i.name=="Element")if(e)e=!1;else return i;return null}function iw(i,e,t){let n=t.tags[nr(i,gs(e))];return n?.children||t.allTags}function pO(i,e){let t=[];for(let n=gs(e);n&&!n.type.isTop;n=gs(n.parent)){let r=nr(i,n);if(r&&n.lastChild.name=="CloseTag")break;r&&t.indexOf(r)<0&&(e.name=="EndTag"||e.from>=n.firstChild.to)&&t.push(r)}return t}var nw=/^[:\-\.\w\u00b7-\uffff]*$/;function Hx(i,e,t,n,r){let s=/\s*>/.test(i.sliceDoc(r,r+5))?"":">",o=gs(t,t.name=="StartTag"||t.name=="TagName");return{from:n,to:r,options:iw(i.doc,o,e).map(l=>({label:l,type:"type"})).concat(pO(i.doc,t).map((l,a)=>({label:"/"+l,apply:"/"+l+s,type:"type",boost:99-a}))),validFor:/^\/?[:\-\.\w\u00b7-\uffff]*$/}}function Kx(i,e,t,n){let r=/\s*>/.test(i.sliceDoc(n,n+5))?"":">";return{from:t,to:n,options:pO(i.doc,e).map((s,o)=>({label:s,apply:s+r,type:"type",boost:99-o})),validFor:nw}}function M5(i,e,t,n){let r=[],s=0;for(let o of iw(i.doc,t,e))r.push({label:"<"+o,type:"type"});for(let o of pO(i.doc,t))r.push({label:"",type:"type",boost:99-s++});return{from:n,to:n,options:r,validFor:/^<\/?[:\-\.\w\u00b7-\uffff]*$/}}function R5(i,e,t,n,r){let s=gs(t),o=s?e.tags[nr(i.doc,s)]:null,l=o&&o.attrs?Object.keys(o.attrs):[],a=o&&o.globalAttrs===!1?l:l.length?l.concat(e.globalAttrNames):e.globalAttrNames;return{from:n,to:r,options:a.map(c=>({label:c,type:"property"})),validFor:nw}}function X5(i,e,t,n,r){var s;let o=(s=t.parent)===null||s===void 0?void 0:s.getChild("AttributeName"),l=[],a;if(o){let c=i.sliceDoc(o.from,o.to),h=e.globalAttrs[c];if(!h){let f=gs(t),u=f?e.tags[nr(i.doc,f)]:null;h=u?.attrs&&u.attrs[c]}if(h){let f=i.sliceDoc(n,r).toLowerCase(),u='"',d='"';/^['"]/.test(f)?(a=f[0]=='"'?/^[^"]*$/:/^[^']*$/,u="",d=i.sliceDoc(r,r+1)==f[0]?"":f[0],f=f.slice(1),n++):a=/^[^\s<>='"]*$/;for(let p of h)l.push({label:p,apply:u+p+d,type:"constant"})}}return{from:n,to:r,options:l,validFor:a}}function rw(i,e){let{state:t,pos:n}=e,r=ee(t).resolveInner(n,-1),s=r.resolve(n);for(let o=n,l;s==r&&(l=r.childBefore(o));){let a=l.lastChild;if(!a||!a.type.isError||a.fromrw(n,r)}var Z5=qt.parser.configure({top:"SingleExpression"}),ow=[{tag:"script",attrs:i=>i.type=="text/typescript"||i.lang=="ts",parser:aO.parser},{tag:"script",attrs:i=>i.type=="text/babel"||i.type=="text/jsx",parser:cO.parser},{tag:"script",attrs:i=>i.type=="text/typescript-jsx",parser:hO.parser},{tag:"script",attrs(i){return/^(importmap|speculationrules|application\/(.+\+)?json)$/i.test(i.type)},parser:Z5},{tag:"script",attrs(i){return!i.type||/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i.test(i.type)},parser:qt.parser},{tag:"style",attrs(i){return(!i.lang||i.lang=="css")&&(!i.type||/^(text\/)?(x-)?(stylesheet|css)$/i.test(i.type))},parser:Jo.parser}],lw=[{name:"style",parser:Jo.parser.configure({top:"Styles"})}].concat(tw.map(i=>({name:i,parser:qt.parser}))),aw=Hi.define({name:"html",parser:wx.configure({props:[$i.add({Element(i){let e=/^(\s*)(<\/)?/.exec(i.textAfter);return i.node.to<=i.pos+e[0].length?i.continue():i.lineIndent(i.node.from)+(e[2]?0:i.unit)},"OpenTag CloseTag SelfClosingTag"(i){return i.column(i.node.from)+i.unit},Document(i){if(i.pos+/\s*/.exec(i.textAfter)[0].lengthi.getChild("TagName")})]}),languageData:{commentTokens:{block:{open:""}},indentOnInput:/^\s*<\/\w+\W$/,wordChars:"-_"}}),rh=aw.configure({wrap:tO(ow,lw)});function cw(i={}){let e="",t;i.matchClosingTags===!1&&(e="noMatch"),i.selfClosingTags===!0&&(e=(e?e+" ":"")+"selfClosing"),(i.nestedLanguages&&i.nestedLanguages.length||i.nestedAttributes&&i.nestedAttributes.length)&&(t=tO((i.nestedLanguages||[]).concat(ow),(i.nestedAttributes||[]).concat(lw)));let n=t?aw.configure({wrap:t,dialect:e}):e?rh.configure({dialect:e}):rh;return new Yt(n,[rh.data.of({autocomplete:E5(i)}),i.autoCloseTags!==!1?L5:[],Fx().support,_x().support])}var Jx=new Set("area base br col command embed frame hr img input keygen link meta param source track wbr menuitem".split(" "));function _5(i,e,t){for(var n;;){if(((n=e.lastChild)===null||n===void 0?void 0:n.name)!="CloseTag")return!1;let r=e.parent;if(!r||nr(i,r)!=t)return!0;e=r}}var L5=M.inputHandler.of((i,e,t,n,r)=>{if(i.composing||i.state.readOnly||e!=t||n!=">"&&n!="/"||!rh.isActiveAt(i.state,e,-1))return!1;let s=r(),{state:o}=s,l=o.changeByRange(a=>{var c;let h=o.doc.sliceString(a.from-1,a.to)==n,{head:f}=a,u=ee(o).resolveInner(f,-1),d;if(h&&n==">"&&u.name=="EndTag"){let p=u.parent;if((d=nr(o.doc,p.parent,f))&&!Jx.has(d)&&!_5(o.doc,p.parent,d)){let O=f+(o.doc.sliceString(f,f+1)===">"?1:0),g=``;return{range:a,changes:{from:f,to:O,insert:g}}}}else if(h&&n=="/"&&u.name=="IncompleteCloseTag"){let p=u.parent;if(u.from==f-2&&((c=p.lastChild)===null||c===void 0?void 0:c.name)!="CloseTag"&&(d=nr(o.doc,p,f))&&!Jx.has(d)){let O=f+(o.doc.sliceString(f,f+1)===">"?1:0),g=`${d}>`;return{range:v.cursor(f+g.length,-1),changes:{from:f,to:O,insert:g}}}}return{range:a}});return l.changes.empty?!1:(i.dispatch([s,o.update(l,{userEvent:"input.complete",scrollIntoView:!0})]),!0)});var uw=Mo({commentTokens:{block:{open:""}}}),dw=new U,pw=WS.configure({props:[fi.add(i=>!i.is("Block")||i.is("Document")||mO(i)!=null||D5(i)?void 0:(e,t)=>({from:t.doc.lineAt(e.from).to,to:e.to})),dw.add(mO),$i.add({Document:()=>null}),Fi.add({Document:uw})]});function mO(i){let e=/^(?:ATX|Setext)Heading(\d)$/.exec(i.name);return e?+e[1]:void 0}function D5(i){return i.name=="OrderedList"||i.name=="BulletList"}function j5(i,e){let t=i;for(;;){let n=t.nextSibling,r;if(!n||(r=mO(n.type))!=null&&r<=e)break;t=n}return t.to}var I5=Ed.of((i,e,t)=>{for(let n=ee(i).resolveInner(t,-1);n&&!(n.fromt)return{from:t,to:s}}return null});function bO(i){return new tt(uw,i,[],"markdown")}var U5=bO(pw),B5=pw.configure([GS,KS,HS,JS,{props:[fi.add({Table:(i,e)=>({from:e.doc.lineAt(i.from).to,to:i.to})})]}]),sh=bO(B5);function V5(i,e){return t=>{if(t&&i){let n=null;if(t=/\S*/.exec(t)[0],typeof i=="function"?n=i(t):n=Ao.matchLanguageName(i,t,!0),n instanceof Ao)return n.support?n.support.language.parser:Co.getSkippingParser(n.load());if(n)return n.parser}return e?e.parser:null}}var il=class{constructor(e,t,n,r,s,o,l){this.node=e,this.from=t,this.to=n,this.spaceBefore=r,this.spaceAfter=s,this.type=o,this.item=l}blank(e,t=!0){let n=this.spaceBefore+(this.node.name=="Blockquote"?">":"");if(e!=null){for(;n.length0;r--)n+=" ";return n+(t?this.spaceAfter:"")}}marker(e,t){let n=this.node.name=="OrderedList"?String(+gw(this.item,e)[2]+t):"";return this.spaceBefore+n+this.type+this.spaceAfter}};function Ow(i,e){let t=[],n=[];for(let r=i;r;r=r.parent){if(r.name=="FencedCode")return n;(r.name=="ListItem"||r.name=="Blockquote")&&t.push(r)}for(let r=t.length-1;r>=0;r--){let s=t[r],o,l=e.lineAt(s.from),a=s.from-l.from;if(s.name=="Blockquote"&&(o=/^ *>( ?)/.exec(l.text.slice(a))))n.push(new il(s,a,a+o[0].length,"",o[1],">",null));else if(s.name=="ListItem"&&s.parent.name=="OrderedList"&&(o=/^( *)\d+([.)])( *)/.exec(l.text.slice(a)))){let c=o[3],h=o[0].length;c.length>=4&&(c=c.slice(0,c.length-4),h-=4),n.push(new il(s.parent,a,a+h,o[1],c,o[2],s))}else if(s.name=="ListItem"&&s.parent.name=="BulletList"&&(o=/^( *)([-+*])( {1,4}\[[ xX]\])?( +)/.exec(l.text.slice(a)))){let c=o[4],h=o[0].length;c.length>4&&(c=c.slice(0,c.length-4),h-=4);let f=o[2];o[3]&&(f+=o[3].replace(/[xX]/," ")),n.push(new il(s.parent,a,a+h,o[1],c,f,s))}}return n}function gw(i,e){return/^(\s*)(\d+)(?=[.)])/.exec(e.sliceString(i.from,i.from+10))}function OO(i,e,t,n=0){for(let r=-1,s=i;;){if(s.name=="ListItem"){let l=gw(s,e),a=+l[2];if(r>=0){if(a!=r+1)return;t.push({from:s.from+l[1].length,to:s.from+l[0].length,insert:String(r+2+n)})}r=a}let o=s.nextSibling;if(!o)break;s=o}}function yO(i,e){let t=/^[ \t]*/.exec(i)[0].length;if(!t||e.facet(Ji)!=" ")return i;let n=Ke(i,4,t),r="";for(let s=n;s>0;)s>=4?(r+=" ",s-=4):(r+=" ",s--);return r+i.slice(t)}var Y5=(i={})=>({state:e,dispatch:t})=>{let n=ee(e),{doc:r}=e,s=null,o=e.changeByRange(l=>{if(!l.empty||!sh.isActiveAt(e,l.from,-1)&&!sh.isActiveAt(e,l.from,1))return s={range:l};let a=l.from,c=r.lineAt(a),h=Ow(n.resolveInner(a,-1),r);for(;h.length&&h[h.length-1].from>a-c.from;)h.pop();if(!h.length)return s={range:l};let f=h[h.length-1];if(f.to-f.spaceAfter.length>a-c.from)return s={range:l};let u=a>=f.to-f.spaceAfter.length&&!/\S/.test(c.text.slice(f.to));if(f.item&&u){if(f.item.from]*$/.test(c.text.slice(0,f.to)))return s={range:l};let b=f.node.firstChild,w=f.node.getChild("ListItem","ListItem");if(b.to>=a||w&&w.to0&&!/[^\s>]/.test(r.lineAt(c.from-1).text)||i.nonTightLists===!1){let x=h.length>1?h[h.length-2]:null,X,C="";x&&x.item?(X=c.from+x.from,C=x.marker(r,1)):X=c.from+(x?x.to:0);let T=[{from:X,to:a,insert:C}];return f.node.name=="OrderedList"&&OO(f.item,r,T,-2),x&&x.node.name=="OrderedList"&&OO(x.item,r,T),{range:v.cursor(X+C.length),changes:T}}else{let x=fw(h,e,c);return{range:v.cursor(a+x.length+1),changes:{from:c.from,insert:x+e.lineBreak}}}}if(f.node.name=="Blockquote"&&u&&c.from){let b=r.lineAt(c.from-1),w=/>\s*$/.exec(b.text);if(w&&w.index==f.from){let x=e.changes([{from:b.from+w.index,to:b.to},{from:c.from+f.from,to:c.to}]);return{range:l.map(x),changes:x}}}let d=[];f.node.name=="OrderedList"&&OO(f.item,r,d);let p=f.item&&f.item.from]*/.exec(c.text)[0].length>=f.to)for(let b=0,w=h.length-1;b<=w;b++)O+=b==w&&!p?h[b].marker(r,1):h[b].blank(bc.from&&/\s/.test(c.text.charAt(g-c.from-1));)g--;return O=yO(O,e),W5(f.node,e.doc)&&(O=fw(h,e,c)+e.lineBreak+O),d.push({from:g,to:a,insert:e.lineBreak+O}),{range:v.cursor(g+O.length+1),changes:d}});return s?!1:(t(e.update(o,{scrollIntoView:!0,userEvent:"input"})),!0)},z5=Y5();function hw(i){return i.name=="QuoteMark"||i.name=="ListMark"}function W5(i,e){if(i.name!="OrderedList"&&i.name!="BulletList")return!1;let t=i.firstChild,n=i.getChild("ListItem","ListItem");if(!n)return!1;let r=e.lineAt(t.to),s=e.lineAt(n.from),o=/^[\s>]*$/.test(r.text);return r.number+(o?0:1){let t=ee(i),n=null,r=i.changeByRange(s=>{let o=s.from,{doc:l}=i;if(s.empty&&sh.isActiveAt(i,s.from)){let a=l.lineAt(o),c=Ow(N5(t,o),l);if(c.length){let h=c[c.length-1],f=h.to-h.spaceAfter.length+(h.spaceAfter?1:0);if(o-a.from>f&&!/\S/.test(a.text.slice(f,o-a.from)))return{range:v.cursor(a.from+f),changes:{from:a.from+f,to:o}};if(o-a.from==f&&(h.item&&a.from<=h.item.from||/^[\s>]*$/.test(a.text.slice(0,h.to)))){let u=a.from+h.from;if(h.item&&h.node.from{var t;let{main:n}=e.state.selection;if(n.empty)return!1;let r=(t=i.clipboardData)===null||t===void 0?void 0:t.getData("text/plain");if(!r||!/^(https?:\/\/|mailto:|xmpp:|www\.)/.test(r)||(/^www\./.test(r)&&(r="https://"+r),!sh.isActiveAt(e.state,n.from,1)))return!1;let s=ee(e.state),o=!1;return s.iterate({from:n.from,to:n.to,enter:l=>{(l.from>n.from||J5.test(l.name))&&(o=!0)},leave:l=>{l.to=7&&g(y),a==null&&(a=X(r,s,c)),w(a,Q)},u=function(y,Q){for(let S=-1;S<=7;S+=1)if(!(y+S<=-1||l<=y+S))for(let $=-1;$<=7;$+=1)Q+$<=-1||l<=Q+$||(0<=S&&S<=6&&($==0||$==6)||0<=$&&$<=6&&(S==0||S==6)||2<=S&&S<=4&&2<=$&&$<=4?o[y+S][Q+$]=!0:o[y+S][Q+$]=!1)},d=function(){let y=0,Q=0;for(let S=0;S<8;S+=1){f(!0,S);let $=hn.getLostPoint(h);(S==0||y>$)&&(y=$,Q=S)}return Q},p=function(){for(let y=8;y>S&1)==1;o[Math.floor(S/3)][S%3+l-8-3]=$}for(let S=0;S<18;S+=1){let $=!y&&(Q>>S&1)==1;o[S%3+l-8-3][Math.floor(S/3)]=$}},b=function(y,Q){let S=s<<3|Q,$=hn.getBCHTypeInfo(S);for(let k=0;k<15;k+=1){let P=!y&&($>>k&1)==1;k<6?o[k][8]=P:k<8?o[k+1][8]=P:o[l-15+k][8]=P}for(let k=0;k<15;k+=1){let P=!y&&($>>k&1)==1;k<8?o[8][l-k-1]=P:k<9?o[8][15-k-1+1]=P:o[8][15-k-1]=P}o[l-8][8]=!y},w=function(y,Q){let S=-1,$=l-1,k=7,P=0,E=hn.getMaskFunction(Q);for(let D=l-1;D>0;D-=2)for(D==6&&(D-=1);;){for(let V=0;V<2;V+=1)if(o[$][D-V]==null){let N=!1;P>>k&1)==1),E($,D-V)&&(N=!N),o[$][D-V]=N,k-=1,k==-1&&(P+=1,k=7)}if($+=S,$<0||l<=$){$-=S,S=-S;break}}},x=function(y,Q){let S=0,$=0,k=0,P=new Array(Q.length),E=new Array(Q.length);for(let j=0;j=0?Ne.getAt(Ft):0}}let D=0;for(let j=0;jP*8)throw"code length overflow. ("+k.getLengthInBits()+">"+P*8+")";for(k.getLengthInBits()+4<=P*8&&k.put(0,4);k.getLengthInBits()%8!=0;)k.putBit(!1);for(;!(k.getLengthInBits()>=P*8||(k.put(236,8),k.getLengthInBits()>=P*8));)k.put(17,8);return x(k,$)};h.addData=function(y,Q){Q=Q||"Byte";let S=null;switch(Q){case"Numeric":S=tR(y);break;case"Alphanumeric":S=iR(y);break;case"Byte":S=nR(y);break;case"Kanji":S=rR(y);break;default:throw"mode:"+Q}c.push(S),a=null},h.isDark=function(y,Q){if(y<0||l<=y||Q<0||l<=Q)throw y+","+Q;return o[y][Q]},h.getModuleCount=function(){return l},h.make=function(){if(r<1){let y=1;for(;y<40;y++){let Q=bw.getRSBlocks(y,s),S=yw();for(let k=0;k"u"?y*4:Q;let S="";S+='";for(let k=0;k';S+=""}return S+="",S+="
      ",S},h.createSvgTag=function(y,Q,S,$){let k={};typeof arguments[0]=="object"&&(k=arguments[0],y=k.cellSize,Q=k.margin,S=k.alt,$=k.title),y=y||2,Q=typeof Q>"u"?y*4:Q,S=typeof S=="string"?{text:S}:S||{},S.text=S.text||null,S.id=S.text?S.id||"qrcode-description":null,$=typeof $=="string"?{text:$}:$||{},$.text=$.text||null,$.id=$.text?$.id||"qrcode-title":null;let P=h.getModuleCount()*y+Q*2,E,D,V,N,j="",H;for(H="l"+y+",0 0,"+y+" -"+y+",0 0,-"+y+"z ",j+=''+C($.text)+"":"",j+=S.text?''+C(S.text)+"":"",j+='',j+='"u"?y*4:Q;let S=h.getModuleCount()*y+Q*2,$=Q,k=S-Q;return aR(S,S,function(P,E){if($<=P&&P"u"?y*4:Q;let $=h.getModuleCount()*y+Q*2,k="";return k+="",k};let C=function(y){let Q="";for(let S=0;S":Q+=">";break;case"&":Q+="&";break;case'"':Q+=""";break;default:Q+=$;break}}return Q},T=function(y){y=typeof y>"u"?2:y;let S=h.getModuleCount()*1+y*2,$=y,k=S-y,P,E,D,V,N,j={"\u2588\u2588":"\u2588","\u2588 ":"\u2580"," \u2588":"\u2584"," ":" "},H={"\u2588\u2588":"\u2580","\u2588 ":"\u2580"," \u2588":" "," ":" "},ie="";for(P=0;P=k?H[N]:j[N];ie+=` +`}return S%2&&y>0?ie.substring(0,ie.length-S-1)+Array(S+1).join("\u2580"):ie.substring(0,ie.length-1)};return h.createASCII=function(y,Q){if(y=y||1,y<2)return T(Q);y-=1,Q=typeof Q>"u"?y*2:Q;let S=h.getModuleCount()*y+Q*2,$=Q,k=S-Q,P,E,D,V,N=Array(y+1).join("\u2588\u2588"),j=Array(y+1).join(" "),H="",ie="";for(P=0;P>>8),s.push(a&255)):s.push(n)}}return s}};var We={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},nl={L:1,M:0,Q:3,H:2},cn={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},hn=(function(){let i=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e=1335,t=7973,n=21522,r={},s=function(o){let l=0;for(;o!=0;)l+=1,o>>>=1;return l};return r.getBCHTypeInfo=function(o){let l=o<<10;for(;s(l)-s(e)>=0;)l^=e<=0;)l^=t<5&&(a+=3+d-5)}for(let f=0;f=256;)n-=255;return i[n]},t})(),rl=function(i,e){if(typeof i.length>"u")throw i.length+"/"+e;let t=(function(){let r=0;for(;r"u")throw"bad rs block @ typeNumber:"+r+"/errorCorrectionLevel:"+s;let l=o.length/3,a=[];for(let c=0;c>>7-n%8&1)==1},t.put=function(n,r){for(let s=0;s>>r-s-1&1)==1)},t.getLengthInBits=function(){return e},t.putBit=function(n){let r=Math.floor(e/8);i.length<=r&&i.push(0),n&&(i[r]|=128>>>e%8),e+=1},t},tR=function(i){let e=We.MODE_NUMBER,t=i,n={};n.getMode=function(){return e},n.getLength=function(o){return t.length},n.write=function(o){let l=t,a=0;for(;a+2>>8&255)*192+(c&255),o.put(c,13),a+=2}if(a>>8)},e.writeBytes=function(t,n,r){n=n||0,r=r||t.length;for(let s=0;s0&&(t+=","),t+=i[n];return t+="]",t},e},sR=function(){let i=0,e=0,t=0,n="",r={},s=function(l){n+=String.fromCharCode(o(l&63))},o=function(l){if(l<0)throw"n:"+l;if(l<26)return 65+l;if(l<52)return 97+(l-26);if(l<62)return 48+(l-52);if(l==62)return 43;if(l==63)return 47;throw"n:"+l};return r.writeByte=function(l){for(i=i<<8|l&255,e+=8,t+=1;e>=6;)s(i>>>e-6),e-=6},r.flush=function(){if(e>0&&(s(i<<6-e),i=0,e=0),t%3!=0){let l=3-t%3;for(let a=0;a=e.length){if(r==0)return-1;throw"unexpected end of file./"+r}let a=e.charAt(t);if(t+=1,a=="=")return r=0,-1;if(a.match(/^\s$/))continue;n=n<<6|o(a.charCodeAt(0)),r+=6}let l=n>>>r-8&255;return r-=8,l};let o=function(l){if(65<=l&&l<=90)return l-65;if(97<=l&&l<=122)return l-97+26;if(48<=l&&l<=57)return l-48+52;if(l==43)return 62;if(l==47)return 63;throw"c:"+l};return s},lR=function(i,e){let t=i,n=e,r=new Array(i*e),s={};s.setPixel=function(c,h,f){r[h*t+c]=f},s.write=function(c){c.writeString("GIF87a"),c.writeShort(t),c.writeShort(n),c.writeByte(128),c.writeByte(0),c.writeByte(0),c.writeByte(0),c.writeByte(0),c.writeByte(0),c.writeByte(255),c.writeByte(255),c.writeByte(255),c.writeString(","),c.writeShort(0),c.writeShort(0),c.writeShort(t),c.writeShort(n),c.writeByte(0);let h=2,f=l(h);c.writeByte(h);let u=0;for(;f.length-u>255;)c.writeByte(255),c.writeBytes(f,u,255),u+=255;c.writeByte(f.length-u),c.writeBytes(f,u,f.length-u),c.writeByte(0),c.writeString(";")};let o=function(c){let h=c,f=0,u=0,d={};return d.write=function(p,O){if(p>>>O)throw"length over";for(;f+O>=8;)h.writeByte(255&(p<>>=8-f,u=0,f=0;u=p<0&&h.writeByte(u)},d},l=function(c){let h=1<//brief/ reaches the page. Every +// asset the HTML loads is named from the site's root (/session.js), or from +// under the base path the page is written for (/abcdesign/session.js), so +// ///brief loads them with or without its trailing slash; the +// page then adds the slash. +package web + +import ( + "bytes" + "embed" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/site" + "github.com/intentdriven/abcd/internal/fsutil" +) + +//go:embed index.html session.js session.css vendor/collab.js vendor/LICENSES.txt +var assets embed.FS + +// Generator is the line the page's HTML carries, which is how a later run +// knows a directory holding the page's files as one it wrote. +const Generator = `` + +// RelayMarker is the line the page carries when the relay serves it itself +// (a local session): the page then takes its own origin as its relay. +const RelayMarker = `` + +// File is one file the page is made of. +type File struct { + Path string // slash-separated, relative to the output directory + Data []byte +} + +// sources maps each written path to the embedded asset it is a copy of. +var sources = []struct{ path, asset string }{ + {"index.html", "index.html"}, + {"404.html", "index.html"}, + {"session.js", "session.js"}, + {"session.css", "session.css"}, + {"vendor/collab.js", "vendor/collab.js"}, + {"vendor/LICENSES.txt", "vendor/LICENSES.txt"}, +} + +// rooted is every root-absolute asset reference the embedded HTML makes, +// each of which a base path moves. +var rooted = []string{`src="/session.js"`, `href="/session.css"`, `href="/vendor/collab.js"`} + +// Files is every file the page is made of, in the order they are written, +// for a site served under base, a path normaliseBasePath returned ("/" is the +// root, whose files are the embedded assets byte for byte). +func Files(base string) []File { + out := make([]File, 0, len(sources)) + for _, s := range sources { + data, err := assets.ReadFile(s.asset) + if err != nil { + // The embed directive names every asset; a missing one is a + // build that cannot happen. + panic(err) + } + if s.asset == "index.html" && base != "/" { + for _, ref := range rooted { + // Each reference is in the embedded HTML once; the tests pin + // it, so a miss is a build that cannot happen. + if bytes.Count(data, []byte(ref)) != 1 { + panic("index.html does not carry " + ref + " once") + } + data = bytes.Replace(data, []byte(ref), []byte(strings.Replace(ref, `"/`, `"`+base, 1)), 1) + } + } + out = append(out, File{Path: s.path, Data: data}) + } + return out +} + +// RelayFiles is the page as a local session's relay serves it itself, keyed +// by the path it is served at: the page's files at the site's root, its HTML +// as "index.html" carrying RelayMarker once, and no 404.html, since the relay +// answers the brief's path itself. +func RelayFiles() map[string][]byte { + out := map[string][]byte{} + for _, f := range Files("/") { + switch f.Path { + case "404.html": + continue + case "index.html": + if bytes.Count(f.Data, []byte(Generator)) != 1 { + // The tests pin the line; a miss is a build that cannot happen. + panic("index.html does not carry the generator line once") + } + f.Data = bytes.Replace(f.Data, []byte(Generator), []byte(Generator+"\n"+RelayMarker), 1) + } + out[f.Path] = f.Data + } + return out +} + +// normaliseBasePath is the base path s names, by the site builder's own rule +// (site.NormaliseBasePath), so the page takes and refuses exactly the paths +// the site it is published beside does: "" and "/" are the root, "abcdesign" +// is "/abcdesign/", and anything but plain path segments is refused, here as +// a Refusal in the page's own words. +func normaliseBasePath(s string) (string, error) { + base, err := site.NormaliseBasePath(s) + if err != nil { + return "", &Refusal{Reason: strings.TrimPrefix(err.Error(), "site: ")} + } + return base, nil +} + +// Result is what Write did. +type Result struct { + Out string `json:"out"` + Files []string `json:"files"` + Bytes int64 `json:"bytes"` + // BasePath is the path the site is served under, "/" at the root. + BasePath string `json:"base_path"` + // Replaced is true when the directory held an earlier copy of the page. + Replaced bool `json:"replaced"` +} + +// Refusal is a directory Write will not write into; nothing was written. +type Refusal struct{ Reason string } + +func (r *Refusal) Error() string { return r.Reason } + +// Write writes the page's files, for a site served under basePath, into out +// and nothing else. out may be absent or empty; a directory holding anything +// but the page's own files, or holding them without the page's own +// index.html, is refused untouched. Nothing is ever removed: an earlier copy +// of the page is overwritten file by file. +func Write(out, basePath string) (Result, error) { + if out == "" { + return Result{}, &Refusal{Reason: "name the directory to write the page into with --out"} + } + base, err := normaliseBasePath(basePath) + if err != nil { + return Result{}, err + } + abs, err := filepath.Abs(out) + if err != nil { + return Result{}, err + } + replaced, err := inspect(abs) + if err != nil { + return Result{}, err + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return Result{}, err + } + if !fsutil.IsRealDir(abs) { + return Result{}, &Refusal{Reason: abs + " is not a real directory; the page is written only into one"} + } + root, err := os.OpenRoot(abs) + if err != nil { + return Result{}, err + } + defer root.Close() + // Asked again through the handle the writes go through, so a directory + // filled between the first look and the first write is still refused. + if _, err := inspectRoot(root, abs); err != nil { + return Result{}, err + } + res := Result{Out: abs, BasePath: base, Replaced: replaced} + for _, f := range Files(base) { + if err := fsutil.WriteFileAtomicInRoot(root, f.Path, f.Data, 0o644); err != nil { + return res, err + } + res.Files = append(res.Files, f.Path) + res.Bytes += int64(len(f.Data)) + } + return res, nil +} + +// inspect judges out before anything is created: absent or empty is fine, a +// symlink or a file is refused, and a directory is judged by inspectRoot. +func inspect(abs string) (bool, error) { + fi, err := os.Lstat(abs) + switch { + case errors.Is(err, fs.ErrNotExist): + return false, nil + case err != nil: + return false, err + case fi.Mode()&os.ModeSymlink != 0: + return false, &Refusal{Reason: abs + " is a symlink; the page is written only into a real directory"} + case !fi.IsDir(): + return false, &Refusal{Reason: abs + " is not a directory"} + } + root, err := os.OpenRoot(abs) + if err != nil { + return false, err + } + defer root.Close() + return inspectRoot(root, abs) +} + +// inspectRoot reports whether the directory holds an earlier copy of the +// page, and refuses one holding anything else. +func inspectRoot(root *os.Root, abs string) (bool, error) { + ours := map[string]bool{"vendor": true} + for _, s := range sources { + ours[s.path] = true + } + var found []string + foreign := "" + err := fs.WalkDir(root.FS(), ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if p == "." { + return nil + } + want := ours[p] + isDir := p == "vendor" + switch { + case !want, d.Type()&fs.ModeSymlink != 0, d.IsDir() != isDir, !d.IsDir() && !d.Type().IsRegular(): + foreign = p + return fs.SkipAll + } + found = append(found, p) + return nil + }) + if err != nil { + return false, err + } + if foreign != "" { + return false, &Refusal{Reason: fmt.Sprintf("%s holds %s, which the session page does not write; point --out at an empty directory or one an earlier `abcd session page` wrote", abs, foreign)} + } + if len(found) == 0 { + return false, nil + } + head, err := fsutil.ReadGuardedInRoot(root, "index.html", 64<<10) + if err != nil || !bytes.Contains(head, []byte(Generator)) { + sort.Strings(found) + return false, &Refusal{Reason: fmt.Sprintf("%s holds %s but no index.html the session page wrote, so it cannot be told apart from a directory it did not write", abs, found[0])} + } + return true, nil +} diff --git a/internal/surface/session/web/web_test.go b/internal/surface/session/web/web_test.go new file mode 100644 index 000000000..87f6ba3bb --- /dev/null +++ b/internal/surface/session/web/web_test.go @@ -0,0 +1,1529 @@ +package web + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// The files the page is made of, and nothing else. +var wantFiles = []string{"404.html", "index.html", "session.css", "session.js", "vendor/LICENSES.txt", "vendor/collab.js"} + +// listTree lists every entry under dir, slash-separated, directories marked +// with a trailing slash. +func listTree(t *testing.T, dir string) []string { + t.Helper() + var got []string + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, p) + if rel == "." { + return nil + } + rel = filepath.ToSlash(rel) + if d.IsDir() { + rel += "/" + } + got = append(got, rel) + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(got) + return got +} + +func TestPageWritesExactlyItsFilesAsEmbedded(t *testing.T) { + out := filepath.Join(t.TempDir(), "page") + res, err := Write(out, "") + if err != nil { + t.Fatal(err) + } + want := append([]string{"vendor/"}, wantFiles...) + sort.Strings(want) + if got := listTree(t, out); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("wrote %v, want %v", got, want) + } + if res.Replaced || len(res.Files) != len(wantFiles) { + t.Fatalf("result = %+v", res) + } + var total int64 + for _, f := range Files("/") { + got, err := os.ReadFile(filepath.Join(out, filepath.FromSlash(f.Path))) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, f.Data) { + t.Errorf("%s differs from the embedded asset", f.Path) + } + // Every embedded asset is the committed file beside this test. + src := f.Path + if src == "404.html" { + src = "index.html" + } + disk, err := os.ReadFile(filepath.FromSlash(src)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, disk) { + t.Errorf("%s differs from the committed %s", f.Path, src) + } + total += int64(len(got)) + } + if res.Bytes != total { + t.Errorf("reported %d bytes, wrote %d", res.Bytes, total) + } + + // A second run replaces its own copy, and still writes nothing else. + res, err = Write(out, "") + if err != nil || !res.Replaced { + t.Fatalf("rewrite: %+v, %v", res, err) + } + if got := listTree(t, out); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("rewrite left %v", got) + } +} + +func Test404IsTheIndex(t *testing.T) { + byPath := map[string][]byte{} + for _, f := range Files("/") { + byPath[f.Path] = f.Data + } + if !bytes.Equal(byPath["404.html"], byPath["index.html"]) || len(byPath["index.html"]) == 0 { + t.Fatal("404.html is not index.html byte for byte") + } +} + +// refused asserts that Write refused out and left it exactly as it was. +func refused(t *testing.T, out string) { + t.Helper() + before := listTree(t, out) + _, err := Write(out, "") + var ref *Refusal + if !errors.As(err, &ref) { + t.Fatalf("Write(%s) = %v, want a refusal", out, err) + } + if after := listTree(t, out); strings.Join(after, ",") != strings.Join(before, ",") { + t.Fatalf("a refusal changed the directory: %v -> %v", before, after) + } +} + +func TestPageRefusesADirectoryItDidNotWrite(t *testing.T) { + t.Run("a foreign file", func(t *testing.T) { + out := t.TempDir() + if err := os.WriteFile(filepath.Join(out, "notes.txt"), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + refused(t, out) + }) + t.Run("a foreign file beside an earlier copy", func(t *testing.T) { + out := t.TempDir() + if _, err := Write(out, ""); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(out, "vendor", "extra.js"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + refused(t, out) + }) + t.Run("the page's names without its index", func(t *testing.T) { + out := t.TempDir() + if err := os.WriteFile(filepath.Join(out, "session.js"), []byte("someone else's\n"), 0o644); err != nil { + t.Fatal(err) + } + refused(t, out) + }) + t.Run("an index.html the page did not write", func(t *testing.T) { + out := t.TempDir() + if err := os.WriteFile(filepath.Join(out, "index.html"), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + refused(t, out) + }) + t.Run("a symlink inside", func(t *testing.T) { + out := t.TempDir() + if _, err := Write(out, ""); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(out, "vendor")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(out, "vendor")); err != nil { + t.Fatal(err) + } + refused(t, out) + }) + t.Run("a symlink or a file as --out", func(t *testing.T) { + dir := t.TempDir() + link := filepath.Join(dir, "link") + if err := os.Symlink(t.TempDir(), link); err != nil { + t.Fatal(err) + } + if _, err := Write(link, ""); !errors.As(err, new(*Refusal)) { + t.Fatalf("symlink --out: %v", err) + } + file := filepath.Join(dir, "file") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Write(file, ""); !errors.As(err, new(*Refusal)) { + t.Fatalf("file --out: %v", err) + } + }) + t.Run("no --out", func(t *testing.T) { + if _, err := Write("", ""); !errors.As(err, new(*Refusal)) { + t.Fatalf("empty --out: %v", err) + } + }) +} + +// provenance is the part of PROVENANCE.json the hash check reads. +type provenance struct { + Bundle string `json:"bundle"` + SHA256 string `json:"sha256"` + Entry string `json:"entry"` + EntrySHA256 string `json:"entry_sha256"` + Packages []struct { + Name, Version, License, Integrity, Resolved string + } `json:"packages"` +} + +// checkProvenance proves the bundle and its entry module in dir are the bytes +// PROVENANCE.json records (adr-2610141949110653). +func checkProvenance(dir string) error { + raw, err := os.ReadFile(filepath.Join(dir, "PROVENANCE.json")) + if err != nil { + return err + } + var p provenance + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + if p.Bundle != "collab.js" || p.Entry != "collab.entry.js" || len(p.Packages) == 0 { + return fmt.Errorf("PROVENANCE.json names %q and %q with %d packages", p.Bundle, p.Entry, len(p.Packages)) + } + for _, c := range []struct{ file, want string }{{p.Bundle, p.SHA256}, {p.Entry, p.EntrySHA256}} { + data, err := os.ReadFile(filepath.Join(dir, c.file)) + if err != nil { + return err + } + sum := sha256.Sum256(data) + if got := hex.EncodeToString(sum[:]); got != c.want { + return fmt.Errorf("%s has sha256 %s, PROVENANCE.json records %s", c.file, got, c.want) + } + } + for _, pkg := range p.Packages { + if pkg.License != "MIT" || !strings.HasPrefix(pkg.Integrity, "sha512-") || !strings.HasPrefix(pkg.Resolved, "https://registry.npmjs.org/") { + return fmt.Errorf("package %s@%s: licence %q, integrity %q, resolved %q", pkg.Name, pkg.Version, pkg.License, pkg.Integrity, pkg.Resolved) + } + } + return nil +} + +func TestVendoredBundleMatchesItsProvenance(t *testing.T) { + if err := checkProvenance("vendor"); err != nil { + t.Fatal(err) + } + embedded, err := assets.ReadFile("vendor/collab.js") + if err != nil { + t.Fatal(err) + } + disk, err := os.ReadFile(filepath.Join("vendor", "collab.js")) + if err != nil || !bytes.Equal(embedded, disk) { + t.Fatalf("the embedded collab.js is not the committed one: %v", err) + } +} + +func TestProvenanceCheckCatchesATamperedBundle(t *testing.T) { + for _, name := range []string{"collab.js", "collab.entry.js"} { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + for _, f := range []string{"PROVENANCE.json", "collab.js", "collab.entry.js"} { + data, err := os.ReadFile(filepath.Join("vendor", f)) + if err != nil { + t.Fatal(err) + } + if f == name { + data = append(data, []byte("\n// tampered\n")...) + } + if err := os.WriteFile(filepath.Join(dir, f), data, 0o644); err != nil { + t.Fatal(err) + } + } + err := checkProvenance(dir) + if err == nil || !strings.Contains(err.Error(), name+" has sha256") { + t.Fatalf("a tampered %s passed the provenance check: %v", name, err) + } + }) + } +} + +// cspWant is the page's content-security policy, in full. +const cspWant = `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; base-uri 'none'; form-action 'none'` + +func TestPageHTMLLoadsOnlyItsOwnFiles(t *testing.T) { + html, err := assets.ReadFile("index.html") + if err != nil { + t.Fatal(err) + } + s := string(html) + for _, want := range []string{ + ``, + ``, + Generator, + ``, + ``, + } { + if strings.Count(s, want) != 1 { + t.Errorf("index.html does not carry %s exactly once", want) + } + } + // Every src and href: the page's own files, named from the site's root + // so ///brief loads them with or without its trailing + // slash, or an empty icon. + allowed := map[string]bool{ + "/session.js": true, "/session.css": true, "/vendor/collab.js": true, "data:,": true, + } + refs := regexp.MustCompile(`(?i)\b(?:src|href)\s*=\s*"([^"]*)"`).FindAllStringSubmatch(s, -1) + if len(refs) != 4 { + t.Errorf("index.html has %d src/href attributes, want 4: %v", len(refs), refs) + } + for _, m := range refs { + if !allowed[m[1]] { + t.Errorf("index.html loads %q", m[1]) + } + } + if n := strings.Count(strings.ToLower(s), " 8000 || limit < 4000 { + t.Errorf("ISSUE_URL_LIMIT = %s, want a margin under GitHub's roughly 8 KB", m[2]) + } + if !strings.Contains(m[1], "2026-10-14-github-roles-admission-and-issue-links.md") { + t.Errorf("the limit's comment does not cite the research note:\n%s", m[1]) + } + // The link is built with encodeURIComponent and measured in bytes, whole. + link := functionBody(t, js, "issueLink") + for _, want := range []string{`"/issues/new?title=" + encodeURIComponent(`, `"&body=" + encodeURIComponent(`, "utf8Length(url) <= ISSUE_URL_LIMIT"} { + if !strings.Contains(link, want) { + t.Errorf("issueLink does not carry %q", want) + } + } + // No labels in the link: GitHub answers 404 to a visitor who may not set them. + for _, bad := range []string{"labels=", "&labels", `"labels"`, "labels:"} { + if strings.Contains(js, bad) { + t.Errorf("session.js carries %q", bad) + } + } + // The issue body names the file, the base commit, both versions and the credit. + body := functionBody(t, js, "issueBody") + for _, want := range []string{"p.path", "baseCommit()", "fenced(p.before)", "fenced(p.after)", "creditLines(p, mdName)"} { + if !strings.Contains(body, want) { + t.Errorf("issueBody does not carry %s", want) + } + } + credit := functionBody(t, js, "creditLines") + if !strings.Contains(credit, `"Suggested by " + show(a.guest) + " (guest), approved by @" + a.approver`) { + t.Error("creditLines does not credit a guest's change to its approver") + } +} + +func TestPullRequestCommitCreditsEveryone(t *testing.T) { + js := pageScript(t) + trailers := functionBody(t, js, "composeTrailers") + for _, want := range []string{ + `"Co-authored-by: " + noreply(`, + `lines.push("Suggested-by: " + p.guest + " (guest)", "Approved-by: " + noreply(`, + "!== submitter.toLowerCase()", + } { + if !strings.Contains(trailers, want) { + t.Errorf("composeTrailers does not carry %q", want) + } + } + if !strings.Contains(functionBody(t, js, "noreply"), `"+" + login + "@users.noreply.github.com>"`) { + t.Error("noreply does not use GitHub's id+login noreply address") + } + pr := functionBody(t, js, "openPullRequest") + for _, want := range []string{ + `"/git/refs"`, `ref: "refs/heads/" + branch, sha: baseSha`, + `"/graphql"`, "createCommitOnBranch", "expectedHeadOid: baseSha", "repositoryNameWithOwner", + `"/pulls"`, "head: branch, base: target", + `"/users/" + encodeURIComponent(`, + "headline: COMMIT_HEADLINE", + } { + if !strings.Contains(pr, want) { + t.Errorf("openPullRequest does not carry %q", want) + } + } + if !strings.Contains(js, `const COMMIT_HEADLINE = "Edit the brief in a live session";`) { + t.Error("the commit headline is not the spec's") + } + if !regexp.MustCompile(`"abcdesign/" \+ `).MatchString(functionBody(t, js, "branchName")) { + t.Error("branchName does not name an abcdesign/ branch") + } +} + +func TestFileContentsAreUTF8Base64(t *testing.T) { + js := pageScript(t) + enc := functionBody(t, js, "utf8Base64") + if !strings.Contains(enc, "new TextEncoder().encode(text)") || !strings.Contains(enc, "btoa(binary)") { + t.Errorf("utf8Base64 does not encode UTF-8 bytes:\n%s", enc) + } + if !strings.Contains(functionBody(t, js, "openPullRequest"), "contents: utf8Base64(") { + t.Error("the commit's file contents do not go through utf8Base64") + } + // btoa sees a binary string built from bytes, never text. + calls := regexp.MustCompile(`btoa\(([^)]*)\)`).FindAllStringSubmatch(js, -1) + if len(calls) == 0 { + t.Fatal("no btoa call found; the check is not looking") + } + for _, c := range calls { + if c[1] != "binary" && c[1] != "s" { + t.Errorf("btoa(%s): btoa is applied only to a binary string built from bytes", c[1]) + } + } + for _, fn := range []string{"b64url", "utf8Base64"} { + body := functionBody(t, js, fn) + if !strings.Contains(body, "String.fromCharCode(") { + t.Errorf("%s does not build its binary string from bytes", fn) + } + } +} + +func TestSuggestionsLiveInTheDocument(t *testing.T) { + js := pageScript(t) + if !strings.Contains(js, `const suggestions = doc.getMap("suggestions");`) { + t.Fatal("suggestions are not a map in the shared document") + } + on := functionBody(t, js, "onSuggest") + if !strings.Contains(on, "!suggestions.has(id)") || !strings.Contains(on, "suggestions.set(id, ") { + t.Errorf("onSuggest does not record a suggestion in the document once:\n%s", on) + } + if strings.Contains(js, "incoming") { + t.Error("session.js still keeps suggestions in page memory (incoming)") + } + if !strings.Contains(functionBody(t, js, "renderSide"), "suggestions.values()") { + t.Error("the members' panel does not read the suggestions from the document") + } +} + +func TestPageRejoinsWithItsTokenBeforeTheCode(t *testing.T) { + js := pageScript(t) + // The token comes with hosted and joined, and is kept in memory and in + // session storage, never in local storage. + for _, fn := range []string{"onHosted", "onJoined"} { + if !strings.Contains(functionBody(t, js, fn), "keepRejoin(msg.rejoin)") { + t.Errorf("%s does not keep the rejoin token", fn) + } + } + if strings.Contains(js, "remember(KEY_REJOIN") || strings.Contains(js, "localStorage.setItem(KEY_REJOIN") { + t.Error("the rejoin token reaches local storage") + } + if !strings.Contains(js, `const REJOIN_RE = /^[A-Za-z0-9_-]{22}$/;`) { + t.Error("the page does not check a rejoin token's shape") + } + // A reconnect presents the token first, before any sign-in or code. + open := functionBody(t, js, "afterOpen") + at := strings.Index(open, `sendJSON({ type: "rejoin", token: rejoinToken });`) + if at < 0 || at > strings.Index(open, `type: "verify"`) { + t.Errorf("afterOpen does not rejoin before it verifies or joins:\n%s", open) + } + // A refused token is dropped and the page falls back to the code. + if !strings.Contains(functionBody(t, js, "onError"), `code === "bad-rejoin"`) { + t.Error("onError does not fall back from a refused rejoin token") + } + // A reload with a kept token for this relay and repository rejoins. + if !strings.Contains(functionBody(t, js, "main"), "if (takeRejoin()) openSocket();") { + t.Error("a reloaded page does not rejoin with its kept token") + } + // So does a page coming back from GitHub's sign-in. + if !strings.Contains(functionBody(t, js, "finishSignIn"), "takeRejoin();\n await presentHeldToken(false);") { + t.Error("a page back from sign-in does not rejoin with its kept token") + } + if !strings.Contains(functionBody(t, js, "endSession"), "forgetRejoin()") { + t.Error("an ended session's rejoin token is kept") + } +} + +func TestPageRedirectsTheBriefPathToItsSlash(t *testing.T) { + js := pageScript(t) + fn := functionBody(t, js, "addSlash") + for _, want := range []string{`/\/[^/]+\/[^/]+\/brief$/.test(location.pathname)`, `location.replace(location.pathname + "/" + location.search + location.hash)`} { + if !strings.Contains(fn, want) { + t.Errorf("addSlash does not carry %q:\n%s", want, fn) + } + } + if !strings.Contains(functionBody(t, js, "main"), "if (addSlash()) return;") { + t.Error("main does not redirect ///brief to its slash first") + } +} + +func TestPageUnderABasePath(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"", "/"}, {"/", "/"}, {"abcdesign", "/abcdesign/"}, {"/abcdesign", "/abcdesign/"}, + {"abcdesign/", "/abcdesign/"}, {"/a/b.c_d~e-f/", "/a/b.c_d~e-f/"}, + } { + if got, err := normaliseBasePath(c.in); err != nil || got != c.want { + t.Errorf("normaliseBasePath(%q) = %q, %v; want %q", c.in, got, err, c.want) + } + } + for _, bad := range []string{"..", "/a/../b", "a//b", "a b", "https://x.example", "a?b", "a#b", "a%20b", `a"b`, "a changes { + t.Errorf("analyse does not refuse a path outside the brief before reading its changes:\n%s", an) + } + if !strings.Contains(an, "refused.push(path)") || !strings.Contains(functionBody(t, js, "renderSend"), "analysis.refused") { + t.Error("a refused path is not named in the Send panel") + } + // Immediately before every additions.push, the path is checked again. + pr := functionBody(t, js, "openPullRequest") + pushes := strings.Count(pr, "additions.push(") + guarded := regexp.MustCompile(`if \(!briefFile\(f\.path, baseFiles\)\) throw new GitHubTrouble\([^\n]*\n\s*additions\.push\(`).FindAllString(pr, -1) + if pushes == 0 || len(guarded) != pushes { + t.Errorf("openPullRequest has %d additions.push, %d guarded by briefFile", pushes, len(guarded)) + } + if !strings.Contains(js, " pathRefused: ") { + t.Error("the page has no words for a refused path") + } +} + +// The vendored editor writes each participant's awareness colour into a +// style attribute and their name into the page, so a remote awareness +// update is decoded and cleaned before it is applied (security review F7). +func TestAwarenessIsCleanedBeforeItIsApplied(t *testing.T) { + js := pageScript(t) + if !strings.Contains(js, "const HEX_COLOUR_RE = /^#[0-9a-fA-F]{6}$/;") { + t.Fatal("session.js has no six-digit hex colour pattern") + } + // The one call that applies a remote awareness update applies a cleaned one. + if n := strings.Count(js, "applyAwarenessUpdate("); n != 1 { + t.Errorf("applyAwarenessUpdate is called %d times, want once", n) + } + if !strings.Contains(functionBody(t, js, "admitAwareness"), "applyAwarenessUpdate(awareness, cleanAwarenessUpdate(") { + t.Error("admitAwareness applies a remote awareness update without cleaning it first") + } + clean := functionBody(t, js, "cleanAwarenessUpdate") + for _, want := range []string{"readVarUint(", "writeVarUint(", "JSON.parse(", "cleanAwarenessState(", "JSON.stringify("} { + if !strings.Contains(clean, want) { + t.Errorf("cleanAwarenessUpdate does not carry %s", want) + } + } + state := functionBody(t, js, "cleanAwarenessState") + for _, want := range []string{"HEX_COLOUR_RE.test(u.color)", "HEX_COLOUR_RE.test(u.colorLight)", "name: label"} { + if !strings.Contains(state, want) { + t.Errorf("cleanAwarenessState does not carry %s:\n%s", want, state) + } + } + // The editor sends a relative position as Yjs builds it, with null for + // a field it does not use (tname, or item at the end of a text): null is + // such a field's absence, not a malformed position, or no cursor shows. + pos := functionBody(t, js, "cleanPosition") + for _, want := range []string{"p.item != null", "p.type != null", "p.tname != null", "p.assoc != null"} { + if !strings.Contains(pos, want) { + t.Errorf("cleanPosition does not take a null field as absent (%s):\n%s", want, pos) + } + } + name := functionBody(t, js, "cleanName") + if !strings.Contains(name, `\u0000-\u001f\u007f-\u009f`) || !strings.Contains(name, ".slice(0, 40)") { + t.Errorf("cleanName does not remove control characters and clamp to 40:\n%s", name) + } +} + +// The relay sends each member's update on as tag 2 with the sender's +// participant number, and the roster names each number's login, so a page +// applies an update only when every client id in it is credited to its +// sender, and credits an approval or a deletion record only to the login +// whose client wrote it (security review F6). +func TestAttributedUpdatesMustBeTheirSendersOwn(t *testing.T) { + js := pageScript(t) + if !strings.Contains(js, "const TAG_ATTRIBUTED = 2;") { + t.Fatal("session.js does not know the relay's attributed update (tag 2)") + } + recv := functionBody(t, js, "receive") + for _, want := range []string{"bytes[0] === TAG_ATTRIBUTED", "getUint32(1)", "admitUpdate(", "noteUnattributed("} { + if !strings.Contains(recv, want) { + t.Errorf("receive does not carry %s", want) + } + } + // Until the roster that names the updates' senders arrives, they wait. + if !strings.Contains(recv, "if (!rosterSeen) held.push(") { + t.Error("receive judges an attributed update before a roster names its sender") + } + roster := functionBody(t, js, "onRoster") + if !strings.Contains(roster, "session.authors = ") || !strings.Contains(roster, "rosterSeen = true;") || !strings.Contains(roster, "admitUpdate(") { + t.Errorf("onRoster does not take the authors and judge the waiting updates:\n%s", roster) + } + forged := functionBody(t, js, "forgedUpdate") + for _, want := range []string{"Y.decodeUpdate(update)", "session.authors[String(num)]", "creditedTo(s.id.client) !== login"} { + if !strings.Contains(forged, want) { + t.Errorf("forgedUpdate does not carry %s:\n%s", want, forged) + } + } + admit := functionBody(t, js, "admitUpdate") + at, apply := strings.Index(admit, "forgedUpdate("), strings.Index(admit, "Y.applyUpdate(") + if at < 0 || apply < 0 || at > apply || !strings.Contains(admit, "WORDS.forgedUpdate") { + t.Errorf("admitUpdate does not drop a forged update, with a warning, before applying:\n%s", admit) + } + // A tag-0 update is applied but credits nobody. + if !strings.Contains(functionBody(t, js, "creditedTo"), "unattributed.has(") { + t.Error("a client id from an unattributed update is still credited") + } + // Approvals and deletion records count only when their writer is theirs. + if strings.Contains(functionBody(t, js, "analyse"), "approvals.toArray()") || !strings.Contains(functionBody(t, js, "analyse"), "verifiedApprovals()") { + t.Error("analyse reads approvals without checking who wrote them") + } + va := functionBody(t, js, "verifiedApprovals") + for _, want := range []string{"item.id.client", "a.client !== writer", "loginOf(writer) !== a.approver) continue;", "a.ins[0] === writer"} { + if !strings.Contains(va, want) { + t.Errorf("verifiedApprovals does not carry %s", want) + } + } + del := functionBody(t, js, "deletersOf") + if strings.Contains(del, "deletions.dss") || !strings.Contains(del, "verifiedDeleters()") { + t.Error("deletersOf reads deletion records without checking who wrote them") + } + vd := functionBody(t, js, "verifiedDeleters") + for _, want := range []string{"item.id.client", "loginOf(desc)"} { + if !strings.Contains(vd, want) { + t.Errorf("verifiedDeleters does not carry %s", want) + } + } + if !strings.Contains(js, " forgedUpdate: ") { + t.Error("the page has no words for a dropped update") + } +} + +// A link can name any relay in its fragment, so no GitHub token and no +// sign-in reaches a relay until the repository's committed relay list names +// it, or, where that list cannot be read, the person has confirmed it +// (security review F1). +func TestNoTokenReachesARelayTheRepositoryDoesNotName(t *testing.T) { + js := pageScript(t) + if !strings.Contains(js, `const RELAYS_FILE = ".abcd/relay/relays.json";`) { + t.Fatal("session.js does not name the repository's relay list") + } + read := functionBody(t, js, "readRelayList") + if !strings.Contains(read, `GITHUB_API + repoPath() + "/contents/" + RELAYS_FILE`) { + t.Errorf("readRelayList does not read the list from api.github.com:\n%s", read) + } + // The anonymous read comes before anything else the page does. + main := functionBody(t, js, "main") + anon, choose, finish := strings.Index(main, `anchor = selfServed() ? { state: "self", relays: [] } : await readRelayList("");`), + strings.Index(main, "session.relay = chooseRelay(linkRelay);"), strings.Index(main, "finishSignIn()") + if anon < 0 || choose < 0 || finish < 0 || anon > choose || choose > finish { + t.Errorf("main does not read the relay list, then choose the relay, before finishing a sign-in:\n%s", main) + } + // Sign-in starts only through a cleared relay, before /config is asked. + start := functionBody(t, js, "startSignIn") + gate, cfg := strings.Index(start, `if (!relayClearedFor("signin")) return;`), strings.Index(start, "relayConfig()") + if gate < 0 || cfg < 0 || gate > cfg { + t.Errorf("startSignIn asks the relay before clearing it:\n%s", start) + } + // GitHub's code goes to the relay's exchange only when it is cleared. + fin := functionBody(t, js, "finishSignIn") + gate, post := strings.Index(fin, "if (!relayCleared())"), strings.Index(fin, `"/auth/token"`) + if gate < 0 || post < 0 || gate > post { + t.Errorf("finishSignIn posts the code before clearing the relay:\n%s", fin) + } + if !strings.Contains(fin, "heldToken = out.access_token;") || !strings.Contains(fin, "await presentHeldToken(false);") { + t.Error("finishSignIn does not hold the token for the relay check") + } + // A token is held, and becomes the token verify sends only once the relay + // is cleared, after api.github.com was asked with it. The device flow's + // token takes the same road as the web flow's. + poll := functionBody(t, js, "pollDevice") + if !strings.Contains(poll, "heldToken = out.access_token;") || !strings.Contains(poll, "await presentHeldToken(false);") || strings.Contains(poll, "githubToken =") { + t.Errorf("the device flow's token skips the relay check:\n%s", poll) + } + present := functionBody(t, js, "presentHeldToken") + ask, give := strings.Index(present, "readRelayList(heldToken)"), strings.Index(present, "githubToken = heldToken;") + if ask < 0 || give < 0 || ask > give || !strings.Contains(present, "forgetRelay(relay)") { + t.Errorf("presentHeldToken does not ask GitHub first and forget a refused relay:\n%s", present) + } + for _, m := range regexp.MustCompile(`githubToken = ([^;]+);`).FindAllStringSubmatch(js, -1) { + if m[1] != `""` && m[1] != "heldToken" { + t.Errorf("githubToken is set from %s, not only from a held token", m[1]) + } + } + for fn, want := range map[string]string{ + "afterOpen": "if (githubToken && relayTokenCleared())", + "connectSignedIn": "if (!githubToken || !relayTokenCleared())", + "onJoined": "githubToken && relayTokenCleared()", + "hostClicked": `if (!relayClearedFor("host")) return;`, + "openSocket": "!relayCleared()", + } { + if !strings.Contains(functionBody(t, js, fn), want) { + t.Errorf("%s does not carry %s", fn, want) + } + } + // The confirmation asks, in plain words, whether the project's team sent + // the link; it shows no address. + if !strings.Contains(js, `trustOnly: "Only continue if someone on the project's team sent you this link."`) { + t.Error("the confirmation does not say to continue only for a link from the project's team") + } + if !strings.Contains(functionBody(t, js, "confirmRelay"), "keepTrusted(all);") { + t.Error("a confirmed relay is not kept per repository") + } + // A token reaches only the relay it was signed in through. + if b := functionBody(t, js, "relayTokenCleared"); !strings.Contains(b, "session.relay === tokenRelay") { + t.Errorf("relayTokenCleared does not hold the token to the relay it was signed in through:\n%s", b) + } +} + +// Sign-in goes through a GitHub App alone, whose user token reaches only the +// repositories the app is installed on: the page never asks for an OAuth +// scope, and a relay whose /config does not say github-app gets no sign-in +// button, only the token paste (security review 2, item 3). +func TestSignInIsAGitHubAppsAlone(t *testing.T) { + js := pageScript(t) + for _, gone := range []string{"SIGN_IN_SCOPE", `q.set("scope"`, "scope:", "oauthNote", `"oauth-app"`} { + if strings.Contains(js, gone) { + t.Errorf("session.js still carries %s", gone) + } + } + cfg := functionBody(t, js, "relayConfig") + if !strings.Contains(cfg, `signIn: cfg.app_type === "github-app" && cfg.sign_in === true`) { + t.Errorf("relayConfig offers sign-in for a relay that is not a GitHub App's:\n%s", cfg) + } + start := functionBody(t, js, "startSignIn") + gate, assign, dev := strings.Index(start, "if (!cfg.signIn) {"), strings.Index(start, "location.assign("), strings.Index(start, "startDeviceSignIn()") + if gate < 0 || assign < 0 || dev < 0 || gate > assign || gate > dev { + t.Errorf("startSignIn signs in without a GitHub App:\n%s", start) + } + if !strings.Contains(js, "docs.github.com/en/apps/") { + t.Error("the GitHub App rule does not cite GitHub's documentation") + } + // No sign-in button for a relay that offers no GitHub App sign-in. + for _, fn := range []string{"renderStart", "renderTop"} { + if !strings.Contains(functionBody(t, js, fn), "signInOffered === false") { + t.Errorf("%s shows a sign-in button whatever the relay offers", fn) + } + } + if !strings.Contains(functionBody(t, js, "refreshSignIn"), "signInOffered = ") { + t.Error("the relay's /config does not decide whether sign-in is offered") + } + // The repository is named prominently beside every sign-in. + notice := functionBody(t, js, "ownerNotice") + for _, want := range []string{"WORDS.signingInTo", "route.slug", `el("strong"`} { + if !strings.Contains(notice, want) { + t.Errorf("ownerNotice does not carry %s:\n%s", want, notice) + } + } + if b := functionBody(t, js, "build"); strings.Count(b, "ownerNotice()") != 2 { + t.Error("build does not name the repository beside both sign-in buttons") + } + if !strings.Contains(functionBody(t, js, "askTrust"), "route.slug") { + t.Error("the trust confirmation does not name the repository") + } +} + +// The device flow is a local session's alone: the page takes a relay's +// `flow: "device"` only when the relay serves the page itself (the anchor is +// "self"). On any other origin, abcdesign.app above all, a relay naming the +// device flow gets no sign-in, and startDeviceSignIn refuses to begin one. +func TestTheStaticPageOffersOnlyTheWebSignIn(t *testing.T) { + js := pageScript(t) + dev := functionBody(t, js, "startDeviceSignIn") + gate, post := strings.Index(dev, `if (anchor.state !== "self")`), strings.Index(dev, `relayPost("/auth/device"`) + if gate < 0 || post < 0 || gate > post { + t.Errorf("startDeviceSignIn begins a device sign-in on a page the relay does not serve:\n%s", dev) + } + start := functionBody(t, js, "startSignIn") + if !strings.Contains(start, `if (cfg.flow === "device" && anchor.state === "self")`) { + t.Errorf("startSignIn takes the device flow whatever serves the page:\n%s", start) + } + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed: relayConfig is checked by its text alone") + } + script := "const GITHUB_AUTHORIZE = \"https://github.com/login/oauth/authorize\";\n" + + "const WORDS = { cannotConnect: \"x\" };\n" + + "let anchor = { state: \"listed\", relays: [] };\n" + + "const session = { relay: \"https://relay.example.org\" };\n" + + "let answer = {};\n" + + "globalThis.fetch = async () => ({ ok: true, json: async () => answer });\n" + + functionBody(t, js, "isLoopbackOrigin") + "\n" + + functionBody(t, js, "relayConfig") + "\n" + + `(async () => { + const out = {}; + const base = { client_id: "Iv1.abc", sign_in: true, app_type: "github-app", authorize_url: GITHUB_AUTHORIZE }; + for (const [name, state, flow] of [["listed-device", "listed", "device"], ["unknown-device", "unknown", "device"], + ["self-device", "self", "device"], ["listed-web", "listed", "web"]]) { + anchor = { state, relays: [] }; + answer = Object.assign({}, base, { flow }); + const c = await relayConfig(); + out[name] = { signIn: c.signIn, flow: c.flow }; + } + console.log(JSON.stringify(out)); +})(); +` + dir := t.TempDir() + path := filepath.Join(dir, "config.mjs") + if err := os.WriteFile(path, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + got, err := exec.Command(node, path).CombinedOutput() + if err != nil { + t.Fatalf("node: %v\n%s", err, got) + } + var out map[string]struct { + SignIn bool `json:"signIn"` + Flow string `json:"flow"` + } + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("node said %s", got) + } + for _, name := range []string{"listed-device", "unknown-device"} { + if c := out[name]; c.SignIn || c.Flow == "device" { + t.Errorf("%s: %+v, want no sign-in on a page the relay does not serve", name, c) + } + } + if c := out["self-device"]; !c.SignIn || c.Flow != "device" { + t.Errorf("a local session's own page: %+v, want the device flow", c) + } + if c := out["listed-web"]; !c.SignIn || c.Flow != "web" { + t.Errorf("the web flow: %+v", c) + } +} + +// The relay list is read fail-closed: only GitHub's 404 means the repository +// names no relay the page can see, which the person may then confirm. A 403, +// a 429, a 5xx or a network error means the list could not be checked: the +// relay is refused, with no confirmation offered (security review 2, item 5). +func TestTheRelayListIsReadFailClosed(t *testing.T) { + js := pageScript(t) + read := functionBody(t, js, "readRelayList") + for _, want := range []string{ + `if (res.status === 404) return { state: "unknown", relays: [] };`, + `if (res.status !== 200) return { state: "unavailable", relays: [] };`, + } { + if !strings.Contains(read, want) { + t.Errorf("readRelayList does not carry %s:\n%s", want, read) + } + } + if strings.Count(read, `state: "unknown"`) != 1 { + t.Errorf("readRelayList reads something other than a 404 as no list:\n%s", read) + } + if !strings.Contains(read, "} catch {\n return { state: \"unavailable\", relays: [] };") { + t.Errorf("a network error is not read as an unavailable list:\n%s", read) + } + if !strings.Contains(functionBody(t, js, "relayStanding"), `if (anchor.state === "unavailable") return "unavailable";`) { + t.Error("relayStanding does not refuse a relay whose list could not be checked") + } + cleared := functionBody(t, js, "relayClearedFor") + if !strings.Contains(cleared, `if (standing === "unconfirmed") askTrust(action);`) || strings.Count(cleared, "askTrust(") != 1 { + t.Errorf("relayClearedFor offers a confirmation for other than an unconfirmed relay:\n%s", cleared) + } + if !strings.Contains(js, `cannotCheck: "The page could not reach GitHub to check this project's settings. Try again in a moment."`) || + !strings.Contains(functionBody(t, js, "standingRefusal"), `if (standing === "unavailable") return WORDS.cannotCheck;`) { + t.Error("the page has no words for a relay list it could not check") + } + if !strings.Contains(functionBody(t, js, "presentHeldToken"), `seen.state === "unavailable"`) { + t.Error("a relay list GitHub would not show to the token is not refused") + } + if !strings.Contains(functionBody(t, js, "trustAnswered"), `relayStanding(p.relay) !== "unconfirmed"`) { + t.Error("a confirmation is honoured for a relay that is not unconfirmed") + } +} + +// The files a session sends back are confined to the base the host seeded, +// read through effectiveMeta, whose base_snapshot only the host's login +// writes: never the member-writable meta map as such (security review 2, +// item 6). +func TestBaseFilesComeFromTheHostsSnapshot(t *testing.T) { + js := pageScript(t) + calls := regexp.MustCompile(`baseFilePaths\(((?:[^()]|\(\))*)\)`).FindAllStringSubmatch(js, -1) + if len(calls) < 3 { + t.Fatalf("baseFilePaths is called %d times; the scan is not looking at the calls", len(calls)) + } + if n := strings.Count(js, "baseFilePaths("); n != len(calls) { + t.Errorf("baseFilePaths( appears %d times, %d of them with a plain argument", n, len(calls)) + } + for _, c := range calls { + switch c[1] { + case "baseSnapshot()", "base": + default: + t.Errorf("baseFilePaths(%s) reads a base other than the host's snapshot", c[1]) + } + } + for _, fn := range []string{"analyse", "validSuggestion"} { + body := functionBody(t, js, fn) + if strings.Contains(body, "baseFilePaths(base)") && !strings.Contains(body, "const base = baseSnapshot();") { + t.Errorf("%s takes its base from elsewhere than baseSnapshot()", fn) + } + } + if !strings.Contains(functionBody(t, js, "baseSnapshot"), "effectiveMeta().base_snapshot") { + t.Error("baseSnapshot does not read the host's base_snapshot") + } + if n := strings.Count(js, "base_snapshot"); n != 3 { + t.Errorf("session.js names base_snapshot %d times, want 3: META_ONCE, seed's write and baseSnapshot", n) + } +} + +// A suggestion a member's page records lands in the session's log, so the +// page keeps only its fields, each validated and bounded, and refuses one +// over the relay's size before sending it (security review 2, item 2). +func TestStoredSuggestionsAreBounded(t *testing.T) { + js := pageScript(t) + for _, want := range []string{ + "const MAX_REPLACEMENT = 64000;", "const MAX_QUOTE_PART = 200;", "const MAX_POSITION_JSON = 256;", "const MAX_SUGGEST_BYTES = 70 * 1024;", + `const SUGGESTION_ID_RE = /^s[0-9]{1,9}$/;`, + } { + if !strings.Contains(js, want) { + t.Errorf("session.js lacks %s", want) + } + } + valid := functionBody(t, js, "validSuggestion") + for _, want := range []string{ + "b.replacement.length <= MAX_REPLACEMENT", "b.quote.exact.length <= MAX_REPLACEMENT", + "b.quote.prefix.length <= MAX_QUOTE_PART", "b.quote.suffix.length <= MAX_QUOTE_PART", + "briefFile(b.path, baseFilePaths(base))", + } { + if !strings.Contains(valid, want) { + t.Errorf("validSuggestion does not check %s:\n%s", want, valid) + } + } + stored := functionBody(t, js, "storedSuggestion") + for _, want := range []string{ + "if (!SUGGESTION_ID_RE.test(id) || !validSuggestion(b)) return null;", "boundedPosition(b.anchor)", "boundedPosition(b.head)", + "from: cleanName(from)", "utf8Length(JSON.stringify(out.body)) > MAX_SUGGEST_BYTES", + } { + if !strings.Contains(stored, want) { + t.Errorf("storedSuggestion does not carry %s:\n%s", want, stored) + } + } + // The relay caps a suggestion's body alone; the id and the name a member's + // page adds must not push a body the relay passed over the cap, or every + // member drops it (final review F). + if strings.Contains(stored, "JSON.stringify(out)) >") { + t.Errorf("storedSuggestion measures the added fields against the relay's cap on the body:\n%s", stored) + } + if strings.Contains(stored, "b.anchor ??") || strings.Contains(stored, "...b") { + t.Errorf("storedSuggestion stores a field as it came:\n%s", stored) + } + bounded := functionBody(t, js, "boundedPosition") + if !strings.Contains(bounded, "cleanPosition(p)") || !strings.Contains(bounded, "MAX_POSITION_JSON") { + t.Errorf("boundedPosition does not clean and bound a position:\n%s", bounded) + } + on := functionBody(t, js, "onSuggest") + if !strings.Contains(on, "const stored = storedSuggestion(id, from, msg.body);") || !strings.Contains(on, "if (!stored) return;") { + t.Errorf("onSuggest records a suggestion it did not bound:\n%s", on) + } + send := functionBody(t, js, "sendSuggestion") + size, sent := strings.Index(send, "utf8Length(JSON.stringify(body)) > MAX_SUGGEST_BYTES"), strings.Index(send, `sendJSON({ type: "suggest", body })`) + if size < 0 || sent < 0 || size > sent { + t.Errorf("sendSuggestion sends a suggestion over the relay's size:\n%s", send) + } + on = functionBody(t, js, "onError") + for _, code := range []string{"suggestion-too-big", "too-many-suggestions", "suggestions-full", "bad-suggestion"} { + if !strings.Contains(on, `"`+code+`"`) { + t.Errorf("onError does not answer %s", code) + } + } + if !strings.Contains(on, "suggestionInFlight = null;") { + t.Error("a refused suggestion stays waiting for its acknowledgement") + } +} + +// The relay's refusal of a suggestion, a rate limit's included, does not say +// which message it answers, so a guest's page has one suggestion in flight at +// a time: the next is refused until the relay answers, an answer of either +// kind clears it, a refusal tells the guest it was not sent, and a connection +// that closes clears it too, since no answer will come. +func TestARefusedSuggestionLeavesNothingWaiting(t *testing.T) { + js := pageScript(t) + if strings.Contains(js, "awaitingAck") { + t.Error("session.js still queues suggestions waiting for their answers") + } + if !strings.Contains(js, "\nlet suggestionInFlight = null;") { + t.Fatal("session.js does not track the one suggestion in flight") + } + send := functionBody(t, js, "sendSuggestion") + wait, set, sent := strings.Index(send, "if (suggestionInFlight) { toast(WORDS.suggestWait); return false; }"), + strings.Index(send, "suggestionInFlight = body;"), strings.Index(send, `sendJSON({ type: "suggest", body })`) + if wait < 0 || set < 0 || sent < 0 || wait > set || set > sent { + t.Errorf("sendSuggestion sends a second suggestion while one waits for its answer:\n%s", send) + } + ok := functionBody(t, js, "onSuggested") + if !strings.Contains(ok, "const body = suggestionInFlight;") || !strings.Contains(ok, "suggestionInFlight = null;") { + t.Errorf("onSuggested does not take the suggestion in flight:\n%s", ok) + } + on := functionBody(t, js, "onError") + // Every refusal of a suggest names kind "suggest", a rate limit's + // included, and any of them clears the one in flight (final review F). + limited := strings.Index(on, `if (msg.kind === "suggest" || `) + if limited < 0 { + t.Fatalf("onError does not clear the suggestion in flight on every refusal of its kind:\n%s", on) + } + // A rate limit names the kind it dropped: a dropped cursor is answered by + // nothing, and never reaches the sign-in branch below it. + other, verifying := strings.Index(on, "if (code === \"rate-limited\") {"), strings.Index(on, "if (session.verifying) {") + if other < 0 || verifying < 0 || other > verifying || limited > other { + t.Errorf("onError lets a dropped cursor read as a failed sign-in, or a dropped suggestion as a cursor:\n%s", on) + } + branch := on[limited:] + branch = branch[:strings.Index(branch, "\n }\n")] + for _, want := range []string{"suggestionInFlight = null;", "clearTimeout(suggestTimer);", "WORDS.suggestTooFast", `WORDS.suggestNotSent + " " + text`, "renderSide();"} { + if !strings.Contains(branch, want) { + t.Errorf("a refused suggestion is not cleared and said (%s):\n%s", want, branch) + } + } + // No answer at all clears it too, after a while. + if !strings.Contains(js, "\nconst SUGGEST_TIMEOUT = 15000;") { + t.Error("a suggestion no answer comes for is never given up on") + } + if !strings.Contains(send, "suggestTimer = setTimeout(suggestionTimedOut, SUGGEST_TIMEOUT);") { + t.Errorf("sendSuggestion starts no timeout:\n%s", send) + } + timedOut := functionBody(t, js, "suggestionTimedOut") + if !strings.Contains(timedOut, "suggestionInFlight = null;") || !strings.Contains(timedOut, "toast(WORDS.suggestLost)") { + t.Errorf("a suggestion past its timeout is not cleared and said:\n%s", timedOut) + } + if !strings.Contains(ok, "clearTimeout(suggestTimer);") { + t.Errorf("an answered suggestion's timeout keeps running:\n%s", ok) + } + for _, want := range []string{" suggestTooFast: ", " suggestNotSent: ", " suggestWait: ", " suggestLost: "} { + if !strings.Contains(js, want) { + t.Errorf("the page has no words %s", strings.TrimSpace(want)) + } + } + closed := functionBody(t, js, "socketClosed") + if !strings.Contains(closed, "suggestionInFlight = null;") || !strings.Contains(closed, "toast(WORDS.suggestLost)") { + t.Errorf("a closed connection leaves its suggestion waiting:\n%s", closed) + } +} + +// The base commit an issue or a pull request names is checked as a commit +// id, and a guest's name cannot write a link, an image, code or emphasis in +// either body (security review 2, item 11a), nor be autolinked: GitHub links +// a bare URL or domain (evil.example/x, www.…, https://…) even with every +// Markdown character escaped, but nothing inside a code span. +func TestBodiesNameOnlyACommitAndInertGuestNames(t *testing.T) { + js := pageScript(t) + if !strings.Contains(js, "const COMMIT_RE = /^[0-9a-f]{40}$/;") { + t.Fatal("session.js has no pattern for a commit id") + } + if !strings.Contains(functionBody(t, js, "baseCommit"), "COMMIT_RE.test(sha)") { + t.Error("baseCommit does not check the base commit") + } + for _, fn := range []string{"issueBody", "prBody"} { + body := functionBody(t, js, fn) + if strings.Contains(body, "effectiveMeta().base_sha") || !strings.Contains(body, "baseCommit()") { + t.Errorf("%s names the base commit unchecked:\n%s", fn, body) + } + if !strings.Contains(body, "creditLines(p, mdName)") { + t.Errorf("%s credits guests without neutralising their names", fn) + } + } + // A backslash escape inside a code span shows as written, so the old + // escaping is gone, not layered under the span. + if strings.Contains(js, "function mdText(") || strings.Contains(js, "mdText(") { + t.Error("session.js still escapes a guest's name instead of putting it in a code span") + } + md := functionBody(t, js, "mdName") + for _, want := range []string{ + "const name = trailerText(s);", + "for (const m of name.matchAll(/`+/g)) longest = Math.max(longest, m[0].length);", + "const fence = \"`\".repeat(longest + 1);", + "const pad = name.startsWith(\"`\") || name.endsWith(\"`\") ? \" \" : \"\";", + "return fence + pad + name + pad + fence;", + } { + if !strings.Contains(md, want) { + t.Errorf("mdName does not carry %s:\n%s", want, md) + } + } + pr := functionBody(t, js, "prBody") + for _, want := range []string{"people.add(mdName(trailerText(r.name))", "people.add(mdName(p.guest)", `out.push("- " + mdName(p.guest)`} { + if !strings.Contains(pr, want) { + t.Errorf("prBody does not carry %s", want) + } + } + if !strings.Contains(functionBody(t, js, "creditLines"), `"Suggested by " + show(a.guest) + " (guest), approved by @" + a.approver`) { + t.Error("creditLines does not pass a guest's name through its formatter") + } +} + +// A static host cannot send frame-ancestors, so the page refuses to run in a +// frame: framed, it shows a message and never the trust confirmation or any +// sign-in (security review 2, item 11b). +func TestThePageRefusesToRunFramed(t *testing.T) { + js := pageScript(t) + if !strings.Contains(functionBody(t, js, "framed"), "return window.top !== window.self;") { + t.Error("framed does not compare the top window with this one") + } + main := functionBody(t, js, "main") + gate, build := strings.Index(main, "if (framed()) { refuseFramed(); return; }"), strings.Index(main, "build()") + if gate < 0 || build < 0 || gate > build { + t.Errorf("main builds the page before refusing a frame:\n%s", main) + } + for _, fn := range []string{"askTrust", "startSignIn"} { + if !strings.Contains(functionBody(t, js, fn), "if (framed()) return;") { + t.Errorf("%s runs inside a frame", fn) + } + } + if !strings.Contains(js, " framedRefused: ") { + t.Error("the page has no words for refusing a frame") + } +} + +// A guest's join reaches a relay only once it is cleared, as a sign-in does: +// the display name is not sent to a relay the person has not confirmed +// (security review 2, item 11d). +func TestAGuestJoinWaitsForTheRelayToBeCleared(t *testing.T) { + js := pageScript(t) + join := functionBody(t, js, "joinClicked") + gate, open := strings.Index(join, `if (!relayClearedFor("join")) return;`), strings.Index(join, "openSocket()") + if gate < 0 || open < 0 || gate > open || gate > strings.Index(join, "proceed()") { + t.Errorf("joinClicked joins before the relay is cleared:\n%s", join) + } + if !strings.Contains(functionBody(t, js, "openSocket"), "!relayCleared()") { + t.Error("openSocket connects to a relay that is not cleared") + } + if !strings.Contains(functionBody(t, js, "trustAnswered"), `p.action === "join"`) { + t.Error("a confirmed relay does not go on to the join") + } +} + +// A session ends twelve hours after it opened; the page says so. +func TestThePageSaysASessionExpired(t *testing.T) { + js := pageScript(t) + if !strings.Contains(js, `case "closed": endSession(msg.reason === "expired" ? WORDS.expired : WORDS.ended); break;`) { + t.Error("the page does not tell an expired session from a closed one") + } + if !strings.Contains(js, " expired: ") { + t.Error("the page has no words for an expired session") + } + // A close that cut a replay short comes without the closed message: its + // reason says the session ended, and the page ends it rather than + // reconnecting (final review A). + closed := functionBody(t, js, "socketClosed") + end, retry := strings.Index(closed, `if (ev.code === 1000 && (ev.reason === "session closed" || ev.reason === "session expired")) {`), strings.Index(closed, "retryTimer = setTimeout(openSocket") + if end < 0 || retry < 0 || end > retry { + t.Errorf("a session the relay ended mid-replay is reconnected to:\n%s", closed) + } +} + +// The relay's errors each have their answer: an idle connection's normal +// close is quiet, a repository the relay does not serve and a pause on +// joining by code are said, and an update dropped for coming too fast is +// sent again. +func TestPageAnswersTheRelaysRefusals(t *testing.T) { + js := pageScript(t) + on := functionBody(t, js, "onError") + for _, want := range []string{`code === "join-timeout"`, `code === "repo-not-served"`, `code === "paused"`, `code === "rate-limited"`, "resendRecent()"} { + if !strings.Contains(on, want) { + t.Errorf("onError does not answer %s", want) + } + } + if !strings.Contains(functionBody(t, js, "socketClosed"), "if (quiet && !session.everJoined) {") { + t.Error("a join-timeout close is reported as a failure to connect") + } + resend := functionBody(t, js, "resendRecent") + if !strings.Contains(resend, "Y.mergeUpdates(recent)") || !strings.Contains(resend, "sendBinary(TAG_UPDATE, merged)") { + t.Errorf("resendRecent does not send the recent updates again, merged:\n%s", resend) + } +} + +// Any member's page can write the shared meta map, so the page counts a +// setting only when the host's login wrote it: the base, the branch and the +// repository once, at seeding, and the mode as the host last set it. Every +// read of meta goes through that one rule, the pull request's target and the +// commit's expected head among them, and a void write is shown. +func TestSessionSettingsAreTheHostsAlone(t *testing.T) { + js := pageScript(t) + if n := strings.Count(js, "meta.get("); n != 0 { + t.Errorf("session.js reads meta.get( %d times outside the one rule", n) + } + if n := strings.Count(js, "meta._map"); n != 1 || !strings.Contains(functionBody(t, js, "effectiveMeta"), "meta._map.get(key)") { + t.Errorf("meta's items are read %d times, want once, in effectiveMeta", n) + } + if !strings.Contains(js, `const META_ONCE = ["base_snapshot", "base_sha", "branch", "repo"];`) { + t.Error("session.js does not name the settings written once") + } + em := functionBody(t, js, "effectiveMeta") + for _, want := range []string{"session.hostLogin", "item.left", "creditedTo(item.id.client)", "META_ONCE.includes(key)", "voided.push("} { + if !strings.Contains(em, want) { + t.Errorf("effectiveMeta does not carry %s:\n%s", want, em) + } + } + for _, fn := range []string{"baseSnapshot", "baseCommit", "openPullRequest", "renderSend", "renderSide"} { + if !strings.Contains(functionBody(t, js, fn), "effectiveMeta()") { + t.Errorf("%s does not read the settings through effectiveMeta", fn) + } + } + pr := functionBody(t, js, "openPullRequest") + for _, want := range []string{"const baseSha = settings.base_sha;", "const target = settings.branch;", "settings.repo", "expectedHeadOid: baseSha,", "base: target,"} { + if !strings.Contains(pr, want) { + t.Errorf("openPullRequest does not carry %s", want) + } + } + roster := functionBody(t, js, "onRoster") + if !strings.Contains(roster, "msg.host") || !strings.Contains(roster, "session.hostLogin = ") || !strings.Contains(roster, "watchMeta()") { + t.Errorf("onRoster does not take the host's login and judge the settings again:\n%s", roster) + } + if !strings.Contains(js, "meta.observe(") || !strings.Contains(functionBody(t, js, "watchMeta"), "WORDS.metaVoid") { + t.Error("a void write to the settings is not shown") + } + if !strings.Contains(js, " metaVoid: ") { + t.Error("the page has no words for a void write to the settings") + } +} + +// The relay sends each awareness message on as tag 3 with its sender's +// participant number, so the page keeps an awareness state only when the +// roster ties its client id to that sender, and labels the cursor from the +// roster, never from the name the message claims. +func TestAwarenessIsTiedToItsSender(t *testing.T) { + js := pageScript(t) + if !strings.Contains(js, "const TAG_ATTRIBUTED_AWARENESS = 3;") { + t.Fatal("session.js does not know the relay's attributed awareness (tag 3)") + } + recv := functionBody(t, js, "receive") + if !strings.Contains(recv, "bytes[0] === TAG_ATTRIBUTED_AWARENESS") || !strings.Contains(recv, "admitAwareness(") { + t.Error("receive does not judge attributed awareness by its sender") + } + if strings.Contains(recv, "bytes[0] === TAG_AWARENESS") { + t.Error("receive still applies awareness that names no sender") + } + admit := functionBody(t, js, "admitAwareness") + for _, want := range []string{"rosterSeen", "awarenessTied(", "cursorLabel("} { + if !strings.Contains(admit, want) { + t.Errorf("admitAwareness does not carry %s:\n%s", want, admit) + } + } + tied := functionBody(t, js, "awarenessTied") + for _, want := range []string{"creditedTo(client) === sender.login", "guestAwareness.get(num)", "guestAwareness.set(num, client)", `creditedTo(client) !== ""`} { + if !strings.Contains(tied, want) { + t.Errorf("awarenessTied does not carry %s:\n%s", want, tied) + } + } + if !strings.Contains(functionBody(t, js, "onRoster"), "guestAwareness.delete(") { + t.Error("a guest's tie is not released when the guest leaves the roster") + } + label := functionBody(t, js, "cursorLabel") + if !strings.Contains(label, "sender.login") || !strings.Contains(label, "cleanName(sender.name)") || !strings.Contains(label, "WORDS.guestCursor") { + t.Errorf("cursorLabel does not label from the roster:\n%s", label) + } +} + +// Every page refuses the same forged update, so an update that depends on +// its clocks would wait in Yjs's pending set for ever: the page sets it +// aside, and says so. +func TestRefusedUpdatesLeaveNothingPending(t *testing.T) { + js := pageScript(t) + admit := functionBody(t, js, "admitUpdate") + if !strings.Contains(admit, "noteRefused(update)") || !strings.Contains(admit, "settlePending()") { + t.Errorf("admitUpdate does not note a refused update and settle what waits on it:\n%s", admit) + } + if !strings.Contains(functionBody(t, js, "noteRefused"), "refusedFrom.set(") { + t.Error("noteRefused does not record the refused clocks") + } + settle := functionBody(t, js, "settlePending") + for _, want := range []string{"store.pendingStructs.missing", "store.pendingStructs = null;", "Y.decodeUpdateV2(store.pendingDs)", "store.pendingDs = null;", "WORDS.setAside"} { + if !strings.Contains(settle, want) { + t.Errorf("settlePending does not carry %s:\n%s", want, settle) + } + } + if !strings.Contains(js, " setAside: ") { + t.Error("the page has no words for edits set aside") + } +} diff --git a/site-src/record.js b/site-src/record.js index 8f2f10145..9646a381e 100644 --- a/site-src/record.js +++ b/site-src/record.js @@ -118,9 +118,12 @@ return { i: i, id: n.id, type: n.type, status: n.lifecycle || n.status || '', title: n.title, path: n.path, kind: n.kind, sev: n.severity, date: n.date, - created: (n.dates && n.dates.created) || n.date, - entered: (n.dates && n.dates.entered) || n.date, - last: (n.dates && n.dates.touched) || n.date, + // A record exported without git dates (a project site) keeps only its + // own date field, shown as that field, never as a git date. + created: n.dates ? (n.dates.created || n.date) : '', + entered: n.dates ? (n.dates.entered || n.date) : '', + last: n.dates ? (n.dates.touched || n.date) : '', + nogit: !n.dates, hx: { date: coil.x * CR, links: links.x * CR }, hy: { date: coil.y * CR, links: links.y * CR }, r: L.radius[i], x: 0, y: 0, vx: 0, vy: 0, a: 1, ta: 1, s: 1 @@ -723,11 +726,12 @@ if (W_.blob) { foot += '' + esc(W_.open) + ' ↗'; } - if (W_.commits && n.last) { + if (W_.commits && (n.last || n.nogit)) { /* The last-touched date LINKS the record's commit history, so an - amendment is traceable from the card rather than merely visible. */ + amendment is traceable from the card rather than merely visible. + A record exported without git dates links its history undated. */ foot += '' + esc(W_.history) + - ' · ' + esc(fmtShort(n.last)) + ' ↗'; + (n.last ? ' · ' + esc(fmtShort(n.last)) : '') + ' ↗'; } card.innerHTML = diff --git a/site-src/site.css b/site-src/site.css index 13614d13c..757317fb4 100644 --- a/site-src/site.css +++ b/site-src/site.css @@ -110,6 +110,11 @@ th{font-family:"Bricolage Grotesque",sans-serif;font-weight:650;font-size:.82rem that duplication would mean changing the markup, not this rule. */ .sr,th .th-portrait~span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +/* ---------- the line every page opens with (itd-2610091010348892) ---------- */ +.managed{margin:0;padding:6px 16px;text-align:center;font-size:.8rem;line-height:1.4;color:var(--ink-2);background:var(--ground);border-bottom:1px solid var(--rule)} +.managed a{color:inherit} +.managed small{font-size:inherit} + /* ---------- site header ---------- */ .site-head{background:var(--ground);border-bottom:1px solid var(--rule)} .site-head .wrap{display:flex;align-items:center;gap:22px;min-height:64px;flex-wrap:wrap} diff --git a/site-src/ui.json b/site-src/ui.json index c0d4be2f4..37bce38d4 100644 --- a/site-src/ui.json +++ b/site-src/ui.json @@ -104,6 +104,7 @@ "read_script": "read the script", "unreleased": "unreleased", "beta": "Beta", + "managed_record": "This is the record of an abcd-managed repository", "health": { "unresolved": "References a record the tree does not hold", "isolated": "Linked to nothing, and nothing links to it",