diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 43e309e..ced8876 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -25,6 +25,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Check RFDs + run: make check-rfds + - name: Setup Elixir uses: erlef/setup-beam@v1 with: diff --git a/Makefile b/Makefile index 61ba12c..216eaa2 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,11 @@ rs-test: .PHONY: lint lint: ex-lint rs-lint +.PHONY: check-rfds +check-rfds: + @./scripts/check-rfd-status.sh + @./scripts/check-rfd-status-test.sh + .PHONY: ex-lint ex-lint: mix credo diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org new file mode 100644 index 0000000..cc62001 --- /dev/null +++ b/rfd/0001/IMPLEMENTATION.org @@ -0,0 +1,16 @@ +#+TITLE: RFD 0001 implementation checklist + +Implements [[file:README.adoc][RFD 1: Administration]]. + +- [ ] A non-admin cannot mount an admin route or obtain admin data by calling a + context function directly. +- [ ] Organization owners and admins have no platform authority unless separately + granted it. +- [ ] An operator can grant the first platform administrator from a release without + manipulating the database manually. +- [ ] Every privilege change, suspension, restoration, and administrative deletion + is audited. +- [ ] Concurrent attempts cannot remove the final platform administrator. +- [ ] Admin list queries are paginated, scoped in SQL, and do not load paste bodies. +- [ ] Authorization, final-admin concurrency, reauthentication, and audit behavior + have context and LiveView tests. diff --git a/rfd/0001/README.adoc b/rfd/0001/README.adoc new file mode 100644 index 0000000..0b1448e --- /dev/null +++ b/rfd/0001/README.adoc @@ -0,0 +1,94 @@ +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: process, software, security + += RFD 1 Administration + +== Goal + +Give trusted operators a narrowly authorized panel for operating the Textbin +installation without conflating platform authority with organization or +workspace roles. + +== Roles and authority + +Textbin adds a platform-level `admin` role. It is independent of organization +`owner` and `admin` memberships: + +* Organization roles grant authority only inside that organization. +* Platform administrators can operate the installation across organizations. +* A platform administrator receives no implicit organization membership and + must not appear as a workspace collaborator. +* Ordinary users have no platform role. Absence of the role is the default. + +Platform authority is stored on the user account or in a dedicated platform +role relation with a database constraint over supported values. Authorization +must not depend on email addresses, configuration allowlists, or UI state. + +== Authentication and authorization + +The admin LiveViews belong inside the existing +`live_session :require_authenticated_user` and +`[:browser, :require_authenticated_user]` pipeline because every admin route +requires a current user. A platform-admin `on_mount` hook then rejects +non-admins before mounting the page. + +Context modules repeat the platform-admin check for every read and mutation so +they remain safe when called by a controller, release task, or future API. A +denied browser request behaves as not found unless showing an explicit forbidden +response is operationally useful; APIs return `403` after authentication. + +Destructive and privilege-changing actions require recent reauthentication. +Administrative sessions use the normal session lifetime; there is no separate +permanent admin session or impersonation feature. + +== Initial administrator bootstrap + +The release exposes an idempotent RPC-compatible function that grants the +platform-admin role to an existing, confirmed user identified by normalized +email. Operators invoke it through the release binary. It must: + +. fail if the user does not exist or is not confirmed; +. lock and update the selected account transactionally; +. report whether authority was granted or already present; and +. append an immutable platform audit event naming the actor as the bootstrap + mechanism. + +The function does not accept or print passwords. Registration and confirmation +remain the normal account-creation path. Revoking the final platform admin is +rejected unless a replacement is granted in the same operation. + +== Panel scope + +The first admin panel provides: + +* installation totals and recent operational failures; +* user lookup by exact email or ID and account status; +* organization and workspace lookup with membership summaries; +* recent and largest paste metadata, without rendering content by default; +* abuse reports and their resolution state; +* administrative paste deletion; +* account suspension and restoration; and +* platform administrator grant and revocation. + +Viewing paste content, impersonating users, editing user content, changing +organization ownership, and reading bearer tokens are not part of the first +version. + +== Audit requirements + +Every administrative mutation records an append-only platform audit event with +the actor, action, target type and ID, timestamp, request ID when available, and +non-secret structured metadata. Bootstrap events identify the release command +rather than inventing a user actor. Audit records never contain paste content, +passwords, session tokens, API tokens, or storage credentials. + +Only platform administrators can read platform audit events. Organization audit +events remain governed by organization authorization and are not a substitute +for the platform log. + +== Implementation + +Progress against this proposal is tracked in the +link:IMPLEMENTATION.org[RFD 1 implementation checklist]. diff --git a/rfd/0002/IMPLEMENTATION.org b/rfd/0002/IMPLEMENTATION.org new file mode 100644 index 0000000..8aac9c6 --- /dev/null +++ b/rfd/0002/IMPLEMENTATION.org @@ -0,0 +1,13 @@ +#+TITLE: RFD 0002 implementation checklist + +Implements [[file:README.adoc][RFD 2: Self-hosting documentation]]. + +- [ ] A fresh operator can identify every required external dependency and durable + path from the guide alone. +- [ ] Local-storage and S3-compatible deployments each have a complete configuration + example and verification procedure. +- [ ] The documented migration, admin-bootstrap, backup, restore, and upgrade + commands execute against the published release image. +- [ ] Documentation clearly separates supported runtime contracts from illustrative + orchestration examples. +- [ ] A restore drill verifies both metadata and external paste content. diff --git a/rfd/0002/README.adoc b/rfd/0002/README.adoc new file mode 100644 index 0000000..7cdf171 --- /dev/null +++ b/rfd/0002/README.adoc @@ -0,0 +1,51 @@ +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: process, software, storage + += RFD 2 Self-hosting documentation + +== Goal + +Document the portable production image well enough that an operator can deploy, +upgrade, back up, restore, and troubleshoot Textbin on their chosen container +platform without requiring Phoenix knowledge. + +== Product boundary + +Textbin publishes an OCI image and documents its runtime contract. The project +does not maintain production Docker Compose, Kubernetes, Terraform, Helm, or +cloud-provider deployment artifacts. Any snippets are illustrative and must not +be represented as production-ready stacks. + +== Required documentation + +The self-hosting guide covers: + +* supported image tags, architectures, digest pinning, and non-root UID/GID; +* required and optional environment variables with secure example generation; +* PostgreSQL version expectations, connection sizing, migrations, and health; +* local and S3-compatible object storage, permissions, persistence, and + connectivity verification; +* temporary upload space sizing and lifecycle; +* reverse-proxy and direct-TLS topologies, forwarded headers, and health checks; +* first-user registration and platform-admin bootstrap; +* rolling and single-node upgrades, rollback constraints, and migration order; +* coordinated PostgreSQL and blob backup/restore order; +* a restore drill with integrity checks rather than backup creation alone; and +* common startup failures and diagnostics that do not expose secrets. + +== Examples + +Examples use placeholders, least-privilege credentials, exact image versions, +and explicit persistent mounts. They must not contain reusable passwords or +suggest exposing PostgreSQL or object storage publicly. + +A minimal example may show one application process and its dependencies, but it +must state that availability, TLS, secret management, monitoring, and backup +scheduling are operator responsibilities. + +== Implementation + +Progress against this proposal is tracked in the +link:IMPLEMENTATION.org[RFD 2 implementation checklist]. diff --git a/rfd/0003/IMPLEMENTATION.org b/rfd/0003/IMPLEMENTATION.org new file mode 100644 index 0000000..b18a24a --- /dev/null +++ b/rfd/0003/IMPLEMENTATION.org @@ -0,0 +1,13 @@ +#+TITLE: RFD 0003 implementation checklist + +Implements [[file:README.adoc][RFD 3: Paste discovery and API contract]]. + +- [ ] Viewer metadata, readable sizes, and line anchors work for anonymous shared + and authenticated private views without weakening authorization. +- [ ] Dashboard and API filters can be combined and retain stable cursor ordering. +- [ ] Search cannot reveal the existence or metadata of an unauthorized paste. +- [ ] Every CLI command's `--json` output is machine-readable and covered by + compatibility tests. +- [ ] API create/show responses contain canonical and raw URLs. +- [ ] Oversized JSON is rejected before full decoding. +- [ ] OpenAPI validation and server contract tests run in CI. diff --git a/rfd/0003/README.adoc b/rfd/0003/README.adoc new file mode 100644 index 0000000..3230a6f --- /dev/null +++ b/rfd/0003/README.adoc @@ -0,0 +1,71 @@ +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, api + += RFD 3 Paste discovery and API contract + +== Goal + +Complete the everyday paste workflow and define one stable API contract used by +the web application, CLI, and generated clients. + +== Paste metadata and viewer + +Pastes gain optional `title` and `filename` metadata with bounded lengths and no +path semantics. The browser viewer displays title or filename, human-readable +size, and stable line anchors. Anchors update the URL fragment without changing +the paste and remain usable when syntax highlighting is disabled. + +Binary pastes remain downloads and are never interpreted as HTML. Metadata in +headers and download filenames is escaped according to the relevant protocol. + +== Dashboard filters, list, recent, and search + +Owner-visible lists support cursor pagination and filters for syntax, +visibility, expiration state, creator, and workspace. `recent` is the newest +active subset of `list`, not a separate persistence model. + +Search is introduced in this order: + +. exact/prefix metadata search over title and filename; +. PostgreSQL full-text search for eligible text paste bodies; +. tags and advanced filters only after their data model is specified. + +Every query is restricted to workspaces and pastes the current scope may read. +Private content never enters a public search index. Public discovery, if added, +is a separate feature and indexes only public pastes. + +== CLI JSON and canonical URLs + +Create, show, delete, list, recent, and search support `--json`. JSON mode writes +one documented JSON value to stdout and diagnostics to stderr. It disables +color, browser opening, and human formatting. + +Paste representations include canonical `url` and `raw_url` values generated +from endpoint configuration. Dates use RFC 3339 UTC strings, IDs remain strings, +and absent optional values are consistently `null` or omitted as defined by the +OpenAPI schema. + +== Request limits + +The Plug JSON parser limit is no greater than the application paste limit plus a +small documented metadata allowance. Oversized requests are rejected before +the complete JSON body is decoded. Raw and multipart paths enforce the same +content limit while streaming. + +== OpenAPI and clients + +OpenAPI covers authentication, token management, paste CRUD, list/recent/search, +raw content, organizations, workspaces, memberships, and invitations. The +document is versioned with the server and checked for drift in CI. + +The CLI consumes the stable v1 behavior rather than private endpoints. A +generated TypeScript client is the first supported SDK. Go and Python clients +are deferred until there is demonstrated demand; generation alone does not make +an SDK supported. + +== Implementation + +Progress against this proposal is tracked in the +link:IMPLEMENTATION.org[RFD 3 implementation checklist]. diff --git a/rfd/0004/IMPLEMENTATION.org b/rfd/0004/IMPLEMENTATION.org new file mode 100644 index 0000000..27c1318 --- /dev/null +++ b/rfd/0004/IMPLEMENTATION.org @@ -0,0 +1,13 @@ +#+TITLE: RFD 0004 implementation checklist + +Implements [[file:README.adoc][RFD 4: Hosted safety and operations]]. + +- [ ] Limits are enforced consistently across browser, raw API, JSON API, and CLI + traffic. +- [ ] Tests cover proxy trust, distributed-limit behavior, retry headers, and key + isolation. +- [ ] Reports can be submitted without exposing reporter identity publicly and can + be resolved only by platform admins. +- [ ] Logs and traces pass tests that reject known secret and content fields. +- [ ] Metrics avoid unbounded labels and expose background cleanup failures. +- [ ] A documented hosted restore drill proves both database and blob recovery. diff --git a/rfd/0004/README.adoc b/rfd/0004/README.adoc new file mode 100644 index 0000000..1177658 --- /dev/null +++ b/rfd/0004/README.adoc @@ -0,0 +1,72 @@ +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, security, metrics + += RFD 4 Hosted safety and operations + +== Goal + +Protect a public Textbin installation from accidental overload and abuse while +providing enough telemetry and recovery procedures to operate it safely. + +== Upload limits + +Limits are evaluated for the authenticated user, API token, source address, and +workspace as applicable. The effective paste-size and expiration limits come +from the most specific entitled scope. A request cannot bypass a workspace +limit by selecting a different client or content encoding. + +Guest, free authenticated, and paid limits are configuration or entitlement +data rather than conditionals spread across controllers. + +== Rate limiting + +Initial rate limits cover authentication attempts, token creation, paste +creation, raw reads, and expensive search operations. Keys include normalized +client address, user ID, token ID, and workspace ID where available. + +Client addresses are trusted only through explicitly configured proxy hops. +Production deployments with multiple application replicas use a shared limiter; +an in-memory limiter is acceptable only for documented single-node operation. +Responses use `429`, include `Retry-After`, and do not disclose whether an +account exists. + +== Abuse controls and reports + +Users can report public or unlisted pastes with a reason category and optional +notes. Reports enter an admin queue with open, actioned, and dismissed states. +Platform admins can remove a paste or suspend an account; both actions require a +reason and create platform audit events. + +Private content is not proactively inspected. Automated blocked-content rules, +if introduced, operate on documented signals and retain only the minimum data +needed for enforcement. Shared unlisted pages use `noindex`; private pages are +never indexable. + +== Structured logs, metrics, and tracing + +Structured request and job logs include request ID, route, status, duration, +error class, storage backend, and relevant non-secret entity IDs. They never +include paste bodies, credentials, authorization headers, or raw session data. + +Metrics cover request and upload counts, size and duration histograms, storage +and database errors, rate-limit decisions, report actions, and expiration-job +outcomes. Labels must have bounded cardinality; user, paste, token, request, and +IP values belong in logs, not metric labels. + +Tracing is optional and disabled by default. When enabled, trace propagation and +sampling are configurable and spans follow the same content-redaction rules. + +== Hosted backups + +Hosted environments automate encrypted PostgreSQL and blob backups, record +their completion, and alert on missed schedules. Restore drills run on an +isolated environment and verify metadata-to-blob checksums. Recovery point and +recovery time objectives must be selected before the schedule and retention +policy can be finalized. + +== Implementation + +Progress against this proposal is tracked in the +link:IMPLEMENTATION.org[RFD 4 implementation checklist]. diff --git a/rfd/0005/IMPLEMENTATION.org b/rfd/0005/IMPLEMENTATION.org new file mode 100644 index 0000000..c3e7d84 --- /dev/null +++ b/rfd/0005/IMPLEMENTATION.org @@ -0,0 +1,14 @@ +#+TITLE: RFD 0005 implementation checklist + +Implements [[file:README.adoc][RFD 5: Workspace collaboration]]. + +- [ ] Invitation token theft alone is insufficient when the authenticated email + does not match the invitation. +- [ ] Concurrent acceptance, revocation, and role changes preserve one membership + and current authorization policy. +- [ ] Workspace defaults are enforced identically for browser, user-token, and + workspace-token creation. +- [ ] Workspace tokens cannot cross workspace boundaries or exercise ungranted + scopes. +- [ ] CLI profile selection never redirects a command to another workspace without + explicit user action. diff --git a/rfd/0005/README.adoc b/rfd/0005/README.adoc new file mode 100644 index 0000000..5df825f --- /dev/null +++ b/rfd/0005/README.adoc @@ -0,0 +1,61 @@ +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, process + += RFD 5 Workspace collaboration + +== Goal + +Complete team workflows around the existing organization, workspace, role, and +workspace-owned paste model. + +== Invitations + +Organization owners and admins can invite an email address to an organization +with an initial organization role. Workspace owners can invite an existing +organization member to their workspace. Invitations are single-use, expire, +store only a token hash, and can be revoked before acceptance. + +Acceptance rechecks the inviter's authority and target organization/workspace at +transaction time. Existing memberships produce an idempotent result rather than +duplicates. Invitation emails and pages do not reveal private organization data +before authentication and email matching. + +== Workspace defaults + +Each workspace can define default visibility and expiration plus an optional +maximum expiration. Creation applies defaults server-side when values are +absent and rejects values outside workspace policy. Defaults never retroactively +change existing pastes. + +Only workspace owners can change workspace defaults. Organization admins do not +implicitly override workspace-owner policy unless that authority is separately +specified. + +== Workspace API tokens + +Workspace tokens represent automation owned by a workspace rather than a user. +They have a name, token hash, creator, last-used timestamp, optional expiration, +and explicit scopes. The raw token is shown once. Revoking the creator's +membership does not silently revoke a workspace-owned token; token lifecycle is +managed explicitly by workspace owners. + +The first scopes are paste read, paste create, and paste delete. A token cannot +manage members, invitations, roles, billing, or other tokens. + +== CLI workflows + +The CLI can list accessible organizations and workspaces, select a default per +profile, override it per command, and clearly display the active destination +before destructive actions. Stored selections use immutable IDs while showing +human-readable names. + +Workspace create/show/delete/list/search use the same public API contract as +other clients. A stale or unauthorized selected workspace returns an actionable +error and never falls back to a personal workspace silently. + +== Implementation + +Progress against this proposal is tracked in the +link:IMPLEMENTATION.org[RFD 5 implementation checklist]. diff --git a/rfd/0006/IMPLEMENTATION.org b/rfd/0006/IMPLEMENTATION.org new file mode 100644 index 0000000..a4d9f3f --- /dev/null +++ b/rfd/0006/IMPLEMENTATION.org @@ -0,0 +1,14 @@ +#+TITLE: RFD 0006 implementation checklist + +Implements [[file:README.adoc][RFD 6: Advanced developer workflows]]. + +- [ ] Redaction tests include false positives, encoded secrets, large input, and + adversarial custom expressions. +- [ ] CI behavior is deterministic without a TTY and never prints tokens. +- [ ] Bundles reject traversal, duplicate paths, archive bombs, and over-limit + manifests before finalization. +- [ ] Diff authorization and resource limits are enforced server-side. +- [ ] Interrupted migration resumes without duplicate content. +- [ ] Client-side encryption ships only after its envelope and key-sharing model + receive an explicit security review. +- [ ] Integration helpers introduce no new server-side trust boundary. diff --git a/rfd/0006/README.adoc b/rfd/0006/README.adoc new file mode 100644 index 0000000..7c342f5 --- /dev/null +++ b/rfd/0006/README.adoc @@ -0,0 +1,75 @@ +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, debug, security + += RFD 6 Advanced developer workflows + +== Goal + +Add higher-level debugging workflows without weakening the core guarantees for +content integrity, authorization, predictable CLI output, and portability. + +== Secret scanning and redaction + +`scan` reports likely secrets without uploading. `--redact-secrets` applies a +versioned built-in rule set locally before upload; custom redaction expressions +are explicit and bounded against pathological runtime. Findings and redacted +values are never sent as telemetry. + +Redaction is best-effort and must not claim that content is secret-free. JSON +output identifies rule names and locations without printing matched secret +values. + +== CI helpers + +CI mode is non-interactive, supports token environment overrides, emits stable +JSON, and returns documented exit codes. Metadata can capture provider and run +identifiers as ordinary bounded fields without granting those values trust. + +== Multi-file bundles + +A bundle has a manifest and multiple named entries. Paths are normalized, +relative, unique, and protected against traversal. Size, file-count, and +compression-expansion limits apply to the complete bundle. Authorization and +expiration apply to the bundle as one object. + +== Diff support + +Diff accepts two authorized text inputs, detects binary content, and enforces +size and compute limits. Server-side diff endpoints do not disclose whether an +unauthorized comparison target exists. Local-versus-remote comparisons may run +entirely in the CLI. + +== Import, export, and migration + +Exports use a versioned manifest, checksums, and streaming content files. +Imports validate the complete manifest and report conflicts before mutation. +Hosted-to-self-hosted migration composes export and import, checkpoints +progress, and is safe to resume without duplicating pastes. + +Workspace exports require workspace-owner authorization. Platform-wide export +is an operator recovery tool, not an admin-panel convenience action. + +== Client-side encryption + +Encryption occurs before upload using a versioned, authenticated-encryption +envelope. Servers store ciphertext and minimal algorithm metadata and cannot +preview, highlight, scan, search, redact, or recover encrypted content. Keys are +never placed in normal URL query parameters, logs, API payload metadata, or +server storage. + +Key derivation, recipient sharing, recovery, browser decryption, and URL-fragment +key UX require a dedicated cryptographic design review before implementation. + +== Local integrations + +Git, Docker, Kubernetes, and journal integrations are thin CLI adapters over +existing commands. They do not shell-expand user input, silently elevate +privileges, or add service-specific server APIs. Each helper shows or documents +the source command and preserves raw bytes until the upload boundary. + +== Implementation + +Progress against this proposal is tracked in the +link:IMPLEMENTATION.org[RFD 6 implementation checklist]. diff --git a/rfd/README.md b/rfd/README.md new file mode 100644 index 0000000..fc98e64 --- /dev/null +++ b/rfd/README.md @@ -0,0 +1,83 @@ +# Textbin Requests for Discussion + +Requests for Discussion (RFDs) capture ideas early enough to shape them through +written discussion and preserve the reasoning behind decisions. An RFD is not +authoritative merely because it exists; its `state` says how it should be read. + +| RFD | Topic | +|---|---| +| [1: Administration](0001/README.adoc) | Platform administrators, authorization, bootstrap, moderation, and internal views | +| [2: Self-hosting documentation](0002/README.adoc) | Operator documentation for deploying and recovering the portable image | +| [3: Paste discovery and API contract](0003/README.adoc) | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | +| [4: Hosted safety and operations](0004/README.adoc) | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | +| [5: Workspace collaboration](0005/README.adoc) | Invitations, defaults, workspace tokens, and CLI workflows | +| [6: Advanced developer workflows](0006/README.adoc) | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | + +## Source format + +Each RFD lives at `rfd/NNNN/README.adoc`, where `NNNN` is a four-digit number. +The document starts with canonical AsciiDoc attributes and an unpadded title: + +```asciidoc +:authors: Name +:state: prediscussion +:discussion: +:labels: software, process + += RFD 7 Example title +``` + +`authors` contains semicolon-separated owners. `discussion` contains the RFD's +pull-request URL once discussion starts. `labels` is a comma-separated set of +searchable topics. The document is the single source of truth for this metadata; +the index intentionally does not duplicate it. + +Implementation progress lives separately in `rfd/NNNN/IMPLEMENTATION.org` for +Textbin's current RFDs. The checker also accepts `IMPLEMENTATION.md`; an RFD must +have exactly one format. The RFD and checklist link to each other, keeping the +design and decision record stable while implementation tasks are checked off. + +Run `make check-rfds` to validate source layout, metadata, state, title, and the +checker regression fixtures. + +## States + +- `prediscussion`: actively being written and not ready for broad review. +- `ideation`: a narrowly scoped topic or scratchpad without active revision. +- `discussion`: under active review in the linked pull request. +- `published`: discussion has converged and the RFD expresses project direction. +- `committed`: the proposal is fully implemented and describes current behavior. +- `abandoned`: deliberately not proceeding or otherwise retained only for history. + +The usual path is `prediscussion` or `ideation` to `discussion`, then +`published`, and eventually `committed`. `abandoned` is an off-ramp at any stage. +Implementation checklist progress does not determine the RFD's state. + +## Lifecycle + +Reserve the next unused four-digit number and create `rfd/NNNN/README.adoc` and +one implementation checklist (`IMPLEMENTATION.org` or `IMPLEMENTATION.md`) on a +branch. Cross-link the two documents. Use `prediscussion` while writing or +`ideation` for a topic placeholder. When the document is ready for review, open +a pull request, set the state to `discussion`, and add that pull request as the +discussion URL. + +Before merging a proposal that represents project direction, move it to +`published`. Once the described work is entirely implemented, update it to +`committed`. Material changes to a published or committed RFD go through a new +pull request and retain the original discussion link unless the RFD explicitly +documents a replacement. + +## Shared principles + +- Context functions enforce authorization; hiding a control in the UI is never + an authorization boundary. +- Browser routes use the existing authenticated router scope when login is + required. API routes use bearer authentication and the same context policies. +- Every collection is scoped in the database query rather than fetched and + filtered afterward. +- Security-sensitive mutations are auditable and require recent + reauthentication where stolen-session risk warrants it. +- Public API changes are specified in OpenAPI before being treated as stable. +- Features work for hosted and self-hosted installations unless an RFD + explicitly identifies them as hosted-only. diff --git a/scripts/check-rfd-status-test.sh b/scripts/check-rfd-status-test.sh new file mode 100755 index 0000000..5974c4d --- /dev/null +++ b/scripts/check-rfd-status-test.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +checker="${repo_root}/scripts/check-rfd-status.sh" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/textbin-rfd-check.XXXXXX")" +rfd_root="${test_root}/rfd" +output="${test_root}/output" + +trap 'rm -rf "${test_root}"' EXIT + +reset_fixtures() { + rm -rf "${rfd_root}" + mkdir -p "${rfd_root}" + printf '# Test RFDs\n' >"${rfd_root}/README.md" +} + +run_success() { + if ! NO_COLOR=1 RFD_DIR="${rfd_root}" bash "${checker}" >"${output}" 2>&1; then + cat "${output}" >&2 + printf 'expected RFD checker to pass\n' >&2 + exit 1 + fi +} + +run_failure() { + local expected="$1" + + if NO_COLOR=1 RFD_DIR="${rfd_root}" bash "${checker}" >"${output}" 2>&1; then + cat "${output}" >&2 + printf 'expected RFD checker to fail with: %s\n' "${expected}" >&2 + exit 1 + fi + + if ! grep -Fq "${expected}" "${output}"; then + cat "${output}" >&2 + printf 'RFD checker failure did not include: %s\n' "${expected}" >&2 + exit 1 + fi +} + +write_valid_rfd() { + local state="$1" + local discussion="$2" + local implementation_format="${3:-org}" + local implementation_name + + case "${implementation_format}" in + org) implementation_name="IMPLEMENTATION.org" ;; + md) implementation_name="IMPLEMENTATION.md" ;; + *) + printf 'unsupported test implementation format: %s\n' "${implementation_format}" >&2 + exit 1 + ;; + esac + + mkdir -p "${rfd_root}/0001" + cat >"${rfd_root}/0001/README.adoc" < +:state: ${state} +:discussion: ${discussion} +:labels: software, process + += RFD 1 Valid RFD + +== Implementation + +See link:${implementation_name}[implementation checklist]. +EOF + + case "${implementation_format}" in + org) + cat >"${rfd_root}/0001/${implementation_name}" <<'EOF' +#+TITLE: RFD 0001 implementation checklist + +Implements [[file:README.adoc][RFD 1: Valid RFD]]. + +- [ ] Complete the work. +EOF + ;; + md) + cat >"${rfd_root}/0001/${implementation_name}" <<'EOF' +# RFD 0001 implementation checklist + +Implements [RFD 1: Valid RFD](README.adoc). + +- [ ] Complete the work. +EOF + ;; + esac +} + +reset_fixtures +write_valid_rfd discussion https://example.com/pull/1 +run_success + +reset_fixtures +write_valid_rfd prediscussion "" md +run_success + +reset_fixtures +write_valid_rfd prediscussion "" +rm "${rfd_root}/0001/IMPLEMENTATION.org" +run_failure "missing implementation checklist" + +reset_fixtures +write_valid_rfd prediscussion "" +printf '\n* [ ] This belongs in the implementation document.\n' >>"${rfd_root}/0001/README.adoc" +run_failure "implementation checkboxes belong in a separate implementation document" + +reset_fixtures +write_valid_rfd prediscussion "" +printf '# RFD 0001 implementation checklist\n\nImplements [RFD 1](README.adoc).\n' >"${rfd_root}/0001/IMPLEMENTATION.md" +run_failure "multiple implementation checklist formats" + +reset_fixtures +write_valid_rfd draft "" +run_failure "invalid state: draft" + +reset_fixtures +write_valid_rfd discussion "" +run_failure "state discussion requires a discussion URL" + +reset_fixtures +write_valid_rfd prediscussion "" +sed -i.bak '1a\ +:authors: Another Author +' "${rfd_root}/0001/README.adoc" +rm "${rfd_root}/0001/README.adoc.bak" +run_failure "exactly one non-empty authors attribute" + +reset_fixtures +write_valid_rfd prediscussion "" +sed -i.bak 's/= RFD 1 /= RFD 2 /' "${rfd_root}/0001/README.adoc" +rm "${rfd_root}/0001/README.adoc.bak" +run_failure "does not match directory number 1" + +reset_fixtures +mkdir -p "${rfd_root}/1" +printf '= RFD 1 Invalid directory\n' >"${rfd_root}/1/README.adoc" +run_failure "invalid RFD entry" + +printf 'RFD checker tests passed.\n' diff --git a/scripts/check-rfd-status.sh b/scripts/check-rfd-status.sh new file mode 100755 index 0000000..2f9a339 --- /dev/null +++ b/scripts/check-rfd-status.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +rfd_root="${RFD_DIR:-${repo_root}/rfd}" + +if [[ -z "${NO_COLOR:-}" && (-t 1 || -n "${FORCE_COLOR:-}") ]]; then + color_reset=$'\033[0m' + color_bold=$'\033[1m' + color_red=$'\033[31m' + color_green=$'\033[32m' + color_yellow=$'\033[33m' + color_blue=$'\033[34m' + color_dim=$'\033[2m' +else + color_reset="" + color_bold="" + color_red="" + color_green="" + color_yellow="" + color_blue="" + color_dim="" +fi + +if [[ ! -d "${rfd_root}" ]]; then + printf "%sRFD directory not found:%s %s\n" "${color_red}" "${color_reset}" "${rfd_root}" >&2 + exit 1 +fi + +colorize_state() { + local state="$1" + local padded="$2" + + case "${state}" in + prediscussion | ideation) printf "%s%s%s" "${color_blue}" "${padded}" "${color_reset}" ;; + discussion) printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" ;; + published | committed) printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" ;; + abandoned) printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" ;; + *) printf "%s%s%s" "${color_red}" "${padded}" "${color_reset}" ;; + esac +} + +read_rfd() { + local source="$1" + local source_name="$2" + local expected_number="$3" + + awk -v source_name="${source_name}" -v expected_number="${expected_number}" ' + function problem(message) { + printf " %s: %s\n", source_name, message > "/dev/stderr" + errors++ + } + + function clean(value) { + sub(/^:[^:]+:[[:space:]]*/, "", value) + if (index(value, "\t") > 0) { + problem("attribute values must not contain tabs") + gsub(/\t/, " ", value) + } + return value + } + + BEGIN { + authors = "" + state = "" + discussion = "" + labels = "" + title = "" + title_number = "" + title_seen = 0 + errors = 0 + } + + !title_seen && $0 ~ /^:authors:[[:space:]]*/ { + authors_count++ + if (authors_count == 1) authors = clean($0) + next + } + + !title_seen && $0 ~ /^:state:[[:space:]]*/ { + state_count++ + if (state_count == 1) state = clean($0) + next + } + + !title_seen && $0 ~ /^:discussion:[[:space:]]*/ { + discussion_count++ + if (discussion_count == 1) discussion = clean($0) + next + } + + !title_seen && $0 ~ /^:labels:[[:space:]]*/ { + labels_count++ + if (labels_count == 1) labels = clean($0) + next + } + + $0 ~ /^= RFD [0-9]+ .+/ { + title_count++ + if (title_count == 1) { + value = $0 + sub(/^= RFD /, "", value) + title_number = value + sub(/ .*/, "", title_number) + title = value + sub(/^[0-9]+ /, "", title) + } + title_seen = 1 + } + + END { + if (authors_count != 1 || authors == "") { + problem("document must contain exactly one non-empty authors attribute") + } else if (authors !~ /<[^>]+>/) { + problem("authors must include a name and address in angle brackets") + } + + if (state_count != 1 || state == "") { + problem("document must contain exactly one non-empty state attribute") + } else if (state !~ /^(prediscussion|ideation|discussion|published|committed|abandoned)$/) { + problem("invalid state: " state) + } + + if (discussion_count != 1) { + problem("document must contain exactly one discussion attribute") + } else if (discussion != "" && discussion !~ /^https?:\/\//) { + problem("discussion must be empty or an HTTP(S) URL") + } else if (state ~ /^(discussion|published|committed)$/ && discussion == "") { + problem("state " state " requires a discussion URL") + } + + if (labels_count != 1 || labels == "") { + problem("document must contain exactly one non-empty labels attribute") + } + + if (title_count != 1 || title == "") { + problem("document must contain exactly one RFD title") + } else if (title_number != expected_number) { + problem("title number " title_number " does not match directory number " expected_number) + } + + printf "%s\t%s\t%s\t%s\t%s\t%d\n", state, title, authors, labels, discussion, errors + } + ' "${source}" +} + +printf "%s%-4s %-13s %-35s %s%s\n" "${color_bold}" "RFD" "State" "Title" "Labels" "${color_reset}" +printf "%s%-4s %-13s %-35s %s%s\n" "${color_dim}" "----" "-------------" "-----------------------------------" "--------------------" "${color_reset}" + +failures=0 +found=0 + +shopt -s nullglob +entries=("${rfd_root}"/*) +shopt -u nullglob + +for entry in "${entries[@]}"; do + entry_name="$(basename "${entry}")" + + if [[ "${entry_name}" == "README.md" ]]; then + continue + fi + + if [[ ! -d "${entry}" || ! "${entry_name}" =~ ^[0-9]{4}$ ]]; then + printf "%sinvalid RFD entry%s: %s\n" "${color_red}" "${color_reset}" "${entry_name}" >&2 + failures=$((failures + 1)) + continue + fi + + found=1 + source="${entry}/README.adoc" + + if [[ ! -f "${source}" ]]; then + printf "%smissing canonical RFD source%s: %s/README.adoc\n" "${color_red}" "${color_reset}" "${entry_name}" >&2 + failures=$((failures + 1)) + continue + fi + + number="$(printf '%s\n' "${entry_name}" | sed 's/^0*//')" + if [[ -z "${number}" ]]; then + number="0" + fi + + implementations=() + [[ -f "${entry}/IMPLEMENTATION.org" ]] && implementations+=("${entry}/IMPLEMENTATION.org") + [[ -f "${entry}/IMPLEMENTATION.md" ]] && implementations+=("${entry}/IMPLEMENTATION.md") + + if [[ "${#implementations[@]}" -eq 0 ]]; then + printf "%smissing implementation checklist%s: %s/IMPLEMENTATION.org or IMPLEMENTATION.md\n" "${color_red}" "${color_reset}" "${entry_name}" >&2 + failures=$((failures + 1)) + elif [[ "${#implementations[@]}" -gt 1 ]]; then + printf "%smultiple implementation checklist formats%s: %s\n" "${color_red}" "${color_reset}" "${entry_name}" >&2 + failures=$((failures + 1)) + else + implementation="${implementations[0]}" + implementation_name="$(basename "${implementation}")" + + case "${implementation_name}" in + IMPLEMENTATION.org) + expected_heading="#+TITLE: RFD ${entry_name} implementation checklist" + backlink="[[file:README.adoc][" + ;; + IMPLEMENTATION.md) + expected_heading="# RFD ${entry_name} implementation checklist" + backlink="](README.adoc)" + ;; + esac + + if [[ "$(head -n 1 "${implementation}")" != "${expected_heading}" ]]; then + printf "%sinvalid implementation checklist heading%s: %s/%s\n" "${color_red}" "${color_reset}" "${entry_name}" "${implementation_name}" >&2 + failures=$((failures + 1)) + fi + + if ! grep -Fq "${backlink}" "${implementation}"; then + printf "%simplementation checklist must link to its RFD%s: %s/%s\n" "${color_red}" "${color_reset}" "${entry_name}" "${implementation_name}" >&2 + failures=$((failures + 1)) + fi + + if ! grep -Fq "link:${implementation_name}[" "${source}"; then + printf "%sRFD must link to its implementation checklist%s: %s/README.adoc\n" "${color_red}" "${color_reset}" "${entry_name}" >&2 + failures=$((failures + 1)) + fi + fi + + if grep -Eq '^\* \[[ xX]\]' "${source}"; then + printf "%simplementation checkboxes belong in a separate implementation document%s: %s/README.adoc\n" "${color_red}" "${color_reset}" "${entry_name}" >&2 + failures=$((failures + 1)) + fi + + parsed="$(read_rfd "${source}" "${entry_name}/README.adoc" "${number}")" + state="${parsed%%$'\t'*}" + remainder="${parsed#*$'\t'}" + title="${remainder%%$'\t'*}" + remainder="${remainder#*$'\t'}" + remainder="${remainder#*$'\t'}" + labels="${remainder%%$'\t'*}" + remainder="${remainder#*$'\t'}" + parser_errors="${remainder##*$'\t'}" + failures=$((failures + parser_errors)) + + state_field="$(printf '%-13s' "${state:-\(missing\)}")" + state_text="$(colorize_state "${state}" "${state_field}")" + printf "%-4s %s %-35s %s\n" "${entry_name}" "${state_text}" "${title:-\(missing title\)}" "${labels:-\(missing labels\)}" +done + +if [[ "${found}" -eq 0 ]]; then + printf "%sno RFDs found%s in %s\n" "${color_red}" "${color_reset}" "${rfd_root}" >&2 + exit 1 +fi + +if [[ "${failures}" -gt 0 ]]; then + echo + printf "%sRFD status check failed%s with %s issue(s).\n" "${color_red}" "${color_reset}" "${failures}" >&2 + exit 1 +fi + +echo +printf "%sRFD status check passed.%s\n" "${color_green}" "${color_reset}"