From b416e2344f08044cbb1857ea423e4ccd2bc2dabe Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 11:47:35 -0700 Subject: [PATCH 1/9] docs: add roadmap specifications --- docs/specs/README.md | 29 +++++++ docs/specs/administration.md | 99 ++++++++++++++++++++++ docs/specs/advanced-developer-workflows.md | 78 +++++++++++++++++ docs/specs/commercial-and-enterprise.md | 64 ++++++++++++++ docs/specs/hosted-safety-and-operations.md | 74 ++++++++++++++++ docs/specs/paste-discovery-and-api.md | 73 ++++++++++++++++ docs/specs/self-hosting-documentation.md | 56 ++++++++++++ docs/specs/workspace-collaboration.md | 64 ++++++++++++++ 8 files changed, 537 insertions(+) create mode 100644 docs/specs/README.md create mode 100644 docs/specs/administration.md create mode 100644 docs/specs/advanced-developer-workflows.md create mode 100644 docs/specs/commercial-and-enterprise.md create mode 100644 docs/specs/hosted-safety-and-operations.md create mode 100644 docs/specs/paste-discovery-and-api.md create mode 100644 docs/specs/self-hosting-documentation.md create mode 100644 docs/specs/workspace-collaboration.md diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 0000000..67fc9f7 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,29 @@ +# Textbin product specifications + +These documents are draft product and engineering contracts for work that has +not yet been scheduled. They define intended outcomes, authorization boundaries, +and acceptance criteria without committing to delivery order. + +| Specification | Covers | +|---|---| +| [Administration](administration.md) | Platform administrators, authorization, bootstrap, moderation, and internal views | +| [Self-hosting documentation](self-hosting-documentation.md) | Operator documentation for deploying and recovering the portable image | +| [Paste discovery and API contract](paste-discovery-and-api.md) | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | +| [Hosted safety and operations](hosted-safety-and-operations.md) | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | +| [Workspace collaboration](workspace-collaboration.md) | Invitations, defaults, workspace tokens, and CLI workflows | +| [Commercial and enterprise](commercial-and-enterprise.md) | Billing, paid limits, SSO, SCIM, domains, compliance, and support | +| [Advanced developer workflows](advanced-developer-workflows.md) | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | + +## 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 should work for both hosted and self-hosted installations unless a + specification explicitly identifies them as hosted-only. diff --git a/docs/specs/administration.md b/docs/specs/administration.md new file mode 100644 index 0000000..df8f2a7 --- /dev/null +++ b/docs/specs/administration.md @@ -0,0 +1,99 @@ +# Administration specification + +## 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: + +1. fail if the user does not exist or is not confirmed; +2. lock and update the selected account transactionally; +3. report whether authority was granted or already present; and +4. 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. + +## Acceptance criteria + +- 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/docs/specs/advanced-developer-workflows.md b/docs/specs/advanced-developer-workflows.md new file mode 100644 index 0000000..0f722e3 --- /dev/null +++ b/docs/specs/advanced-developer-workflows.md @@ -0,0 +1,78 @@ +# Advanced developer workflows specification + +## 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. + +## Acceptance criteria + +- 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/docs/specs/commercial-and-enterprise.md b/docs/specs/commercial-and-enterprise.md new file mode 100644 index 0000000..ee3ba88 --- /dev/null +++ b/docs/specs/commercial-and-enterprise.md @@ -0,0 +1,64 @@ +# Commercial and enterprise specification + +## Goal + +Add hosted commercial capabilities only after the stable API, administration, +and hosted-safety foundations exist. + +## Billing and higher limits + +Billing is organization-based. A subscription controls entitlements such as +paste size, storage, retention, member count, and API-token count. Provider +webhooks are authenticated, idempotent, persisted before processing, and safe +to replay. Billing status never becomes an authorization substitute for +organization roles. + +Downgrades do not delete content immediately. They block new over-limit actions +and provide a documented grace policy. Exact plans, prices, provider, tax +handling, grace periods, and quotas require a separate commercial decision. + +## Enterprise identity + +OIDC is implemented before SAML unless customer demand changes the order. +Domain discovery does not automatically merge accounts. Linking an external +identity requires authenticated proof and preserves a recovery path. + +SCIM provisioning is organization-scoped, uses independently revocable bearer +credentials, and handles retries idempotently. Deprovisioning removes managed +organization access without deleting the underlying user or unrelated +memberships. + +## Custom domains + +Custom domains require verified DNS control, certificate issuance and renewal, +collision prevention, and canonical URL behavior. Authentication cookies remain +bound to the primary Textbin domain unless a dedicated custom-domain auth model +is specified. + +## Audit and compliance exports + +Organization audit coverage expands to paste lifecycle, sharing-policy changes, +membership and invitation changes, token lifecycle, workspace settings, and +enterprise identity events. Reads of paste content are not logged by default; +enabling them requires a privacy and volume policy. + +Exports are generated asynchronously, encrypted at rest, short-lived, and +available only to authorized organization owners. They include a manifest and +checksums and never include credential material. + +## Support and SLA packaging + +Support tiers define channels, support hours, severity levels, response targets, +service exclusions, and status-communication behavior. Availability promises +are not published until monitoring and incident response can measure them. + +## Acceptance criteria + +- Entitlements are deterministic, auditable, and resilient to duplicated or + reordered billing webhooks. +- Billing failures cannot grant authority or expose another organization's data. +- OIDC, SAML, and SCIM identities are tenant-scoped and cannot be claimed by an + unrelated organization. +- Custom-domain verification and certificate renewal fail closed. +- Exports are authorized at download time and expire automatically. +- Published SLA measurements match the actual monitoring definition. diff --git a/docs/specs/hosted-safety-and-operations.md b/docs/specs/hosted-safety-and-operations.md new file mode 100644 index 0000000..35fef00 --- /dev/null +++ b/docs/specs/hosted-safety-and-operations.md @@ -0,0 +1,74 @@ +# Hosted safety and operations specification + +## 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. + +## Acceptance criteria + +- 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/docs/specs/paste-discovery-and-api.md b/docs/specs/paste-discovery-and-api.md new file mode 100644 index 0000000..0c04dd4 --- /dev/null +++ b/docs/specs/paste-discovery-and-api.md @@ -0,0 +1,73 @@ +# Paste discovery and API contract specification + +## 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: + +1. exact/prefix metadata search over title and filename; +2. PostgreSQL full-text search for eligible text paste bodies; +3. 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. + +## Acceptance criteria + +- 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/docs/specs/self-hosting-documentation.md b/docs/specs/self-hosting-documentation.md new file mode 100644 index 0000000..fc2d7b7 --- /dev/null +++ b/docs/specs/self-hosting-documentation.md @@ -0,0 +1,56 @@ +# Self-hosting documentation specification + +## 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. + +The repository's Compose configuration may continue to support local +development dependencies. It is not a supported self-hosted topology. + +## 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. + +## Acceptance criteria + +- 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/docs/specs/workspace-collaboration.md b/docs/specs/workspace-collaboration.md new file mode 100644 index 0000000..97029aa --- /dev/null +++ b/docs/specs/workspace-collaboration.md @@ -0,0 +1,64 @@ +# Workspace collaboration specification + +## 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. + +## Acceptance criteria + +- 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. From 88c0b95646d0e433de950b8a5dea95aa7093bfff Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 12:18:33 -0700 Subject: [PATCH 2/9] docs: organize roadmap as numbered RFCs --- Makefile | 4 + .../0001-administration.md} | 22 +- .../0002-self-hosting-documentation.md} | 18 +- .../0003-paste-discovery-and-api.md} | 22 +- .../0004-hosted-safety-and-operations.md} | 20 +- .../0005-workspace-collaboration.md} | 18 +- .../0006-commercial-and-enterprise.md} | 20 +- .../0007-advanced-developer-workflows.md} | 22 +- docs/rfcs/README.md | 32 +++ docs/specs/README.md | 29 --- scripts/check-rfc-status.sh | 224 ++++++++++++++++++ 11 files changed, 352 insertions(+), 79 deletions(-) rename docs/{specs/administration.md => rfcs/0001-administration.md} (85%) rename docs/{specs/self-hosting-documentation.md => rfcs/0002-self-hosting-documentation.md} (80%) rename docs/{specs/paste-discovery-and-api.md => rfcs/0003-paste-discovery-and-api.md} (81%) rename docs/{specs/hosted-safety-and-operations.md => rfcs/0004-hosted-safety-and-operations.md} (83%) rename docs/{specs/workspace-collaboration.md => rfcs/0005-workspace-collaboration.md} (82%) rename docs/{specs/commercial-and-enterprise.md => rfcs/0006-commercial-and-enterprise.md} (80%) rename docs/{specs/advanced-developer-workflows.md => rfcs/0007-advanced-developer-workflows.md} (82%) create mode 100644 docs/rfcs/README.md delete mode 100644 docs/specs/README.md create mode 100755 scripts/check-rfc-status.sh diff --git a/Makefile b/Makefile index 61ba12c..035e12a 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,10 @@ rs-test: .PHONY: lint lint: ex-lint rs-lint +.PHONY: check-rfcs +check-rfcs: + ./scripts/check-rfc-status.sh + .PHONY: ex-lint ex-lint: mix credo diff --git a/docs/specs/administration.md b/docs/rfcs/0001-administration.md similarity index 85% rename from docs/specs/administration.md rename to docs/rfcs/0001-administration.md index df8f2a7..83ca9ba 100644 --- a/docs/specs/administration.md +++ b/docs/rfcs/0001-administration.md @@ -1,4 +1,10 @@ -# Administration specification +--- +rfc: 0001 +title: Administration +status: Draft +--- + +# RFC 0001: Administration ## Goal @@ -85,15 +91,15 @@ for the platform log. ## Acceptance criteria -- A non-admin cannot mount an admin route or obtain admin data by calling a +- [ ] 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 +- [ ] Organization owners and admins have no platform authority unless separately granted it. -- An operator can grant the first platform administrator from a release without +- [ ] An operator can grant the first platform administrator from a release without manipulating the database manually. -- Every privilege change, suspension, restoration, and administrative deletion +- [ ] 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 +- [ ] 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/docs/specs/self-hosting-documentation.md b/docs/rfcs/0002-self-hosting-documentation.md similarity index 80% rename from docs/specs/self-hosting-documentation.md rename to docs/rfcs/0002-self-hosting-documentation.md index fc2d7b7..77103f7 100644 --- a/docs/specs/self-hosting-documentation.md +++ b/docs/rfcs/0002-self-hosting-documentation.md @@ -1,4 +1,10 @@ -# Self-hosting documentation specification +--- +rfc: 0002 +title: Self-hosting documentation +status: Draft +--- + +# RFC 0002: Self-hosting documentation ## Goal @@ -45,12 +51,12 @@ scheduling are operator responsibilities. ## Acceptance criteria -- A fresh operator can identify every required external dependency and durable +- [ ] 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 +- [ ] Local-storage and S3-compatible deployments each have a complete configuration example and verification procedure. -- The documented migration, admin-bootstrap, backup, restore, and upgrade +- [ ] The documented migration, admin-bootstrap, backup, restore, and upgrade commands execute against the published release image. -- Documentation clearly separates supported runtime contracts from illustrative +- [ ] Documentation clearly separates supported runtime contracts from illustrative orchestration examples. -- A restore drill verifies both metadata and external paste content. +- [ ] A restore drill verifies both metadata and external paste content. diff --git a/docs/specs/paste-discovery-and-api.md b/docs/rfcs/0003-paste-discovery-and-api.md similarity index 81% rename from docs/specs/paste-discovery-and-api.md rename to docs/rfcs/0003-paste-discovery-and-api.md index 0c04dd4..0d1134e 100644 --- a/docs/specs/paste-discovery-and-api.md +++ b/docs/rfcs/0003-paste-discovery-and-api.md @@ -1,4 +1,10 @@ -# Paste discovery and API contract specification +--- +rfc: 0003 +title: Paste discovery and API contract +status: Draft +--- + +# RFC 0003: Paste discovery and API contract ## Goal @@ -62,12 +68,12 @@ an SDK supported. ## Acceptance criteria -- Viewer metadata, readable sizes, and line anchors work for anonymous shared +- [ ] 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 +- [ ] 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. +- [ ] 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/docs/specs/hosted-safety-and-operations.md b/docs/rfcs/0004-hosted-safety-and-operations.md similarity index 83% rename from docs/specs/hosted-safety-and-operations.md rename to docs/rfcs/0004-hosted-safety-and-operations.md index 35fef00..6d613f6 100644 --- a/docs/specs/hosted-safety-and-operations.md +++ b/docs/rfcs/0004-hosted-safety-and-operations.md @@ -1,4 +1,10 @@ -# Hosted safety and operations specification +--- +rfc: 0004 +title: Hosted safety and operations +status: Draft +--- + +# RFC 0004: Hosted safety and operations ## Goal @@ -63,12 +69,12 @@ policy can be finalized. ## Acceptance criteria -- Limits are enforced consistently across browser, raw API, JSON API, and CLI +- [ ] Limits are enforced consistently across browser, raw API, JSON API, and CLI traffic. -- Tests cover proxy trust, distributed-limit behavior, retry headers, and key +- [ ] Tests cover proxy trust, distributed-limit behavior, retry headers, and key isolation. -- Reports can be submitted without exposing reporter identity publicly and can +- [ ] 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. +- [ ] 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/docs/specs/workspace-collaboration.md b/docs/rfcs/0005-workspace-collaboration.md similarity index 82% rename from docs/specs/workspace-collaboration.md rename to docs/rfcs/0005-workspace-collaboration.md index 97029aa..f59df0c 100644 --- a/docs/specs/workspace-collaboration.md +++ b/docs/rfcs/0005-workspace-collaboration.md @@ -1,4 +1,10 @@ -# Workspace collaboration specification +--- +rfc: 0005 +title: Workspace collaboration +status: Draft +--- + +# RFC 0005: Workspace collaboration ## Goal @@ -52,13 +58,13 @@ error and never falls back to a personal workspace silently. ## Acceptance criteria -- Invitation token theft alone is insufficient when the authenticated email +- [ ] Invitation token theft alone is insufficient when the authenticated email does not match the invitation. -- Concurrent acceptance, revocation, and role changes preserve one membership +- [ ] Concurrent acceptance, revocation, and role changes preserve one membership and current authorization policy. -- Workspace defaults are enforced identically for browser, user-token, and +- [ ] Workspace defaults are enforced identically for browser, user-token, and workspace-token creation. -- Workspace tokens cannot cross workspace boundaries or exercise ungranted +- [ ] Workspace tokens cannot cross workspace boundaries or exercise ungranted scopes. -- CLI profile selection never redirects a command to another workspace without +- [ ] CLI profile selection never redirects a command to another workspace without explicit user action. diff --git a/docs/specs/commercial-and-enterprise.md b/docs/rfcs/0006-commercial-and-enterprise.md similarity index 80% rename from docs/specs/commercial-and-enterprise.md rename to docs/rfcs/0006-commercial-and-enterprise.md index ee3ba88..2ab68ae 100644 --- a/docs/specs/commercial-and-enterprise.md +++ b/docs/rfcs/0006-commercial-and-enterprise.md @@ -1,4 +1,10 @@ -# Commercial and enterprise specification +--- +rfc: 0006 +title: Commercial and enterprise +status: Draft +--- + +# RFC 0006: Commercial and enterprise ## Goal @@ -54,11 +60,11 @@ are not published until monitoring and incident response can measure them. ## Acceptance criteria -- Entitlements are deterministic, auditable, and resilient to duplicated or +- [ ] Entitlements are deterministic, auditable, and resilient to duplicated or reordered billing webhooks. -- Billing failures cannot grant authority or expose another organization's data. -- OIDC, SAML, and SCIM identities are tenant-scoped and cannot be claimed by an +- [ ] Billing failures cannot grant authority or expose another organization's data. +- [ ] OIDC, SAML, and SCIM identities are tenant-scoped and cannot be claimed by an unrelated organization. -- Custom-domain verification and certificate renewal fail closed. -- Exports are authorized at download time and expire automatically. -- Published SLA measurements match the actual monitoring definition. +- [ ] Custom-domain verification and certificate renewal fail closed. +- [ ] Exports are authorized at download time and expire automatically. +- [ ] Published SLA measurements match the actual monitoring definition. diff --git a/docs/specs/advanced-developer-workflows.md b/docs/rfcs/0007-advanced-developer-workflows.md similarity index 82% rename from docs/specs/advanced-developer-workflows.md rename to docs/rfcs/0007-advanced-developer-workflows.md index 0f722e3..059d88e 100644 --- a/docs/specs/advanced-developer-workflows.md +++ b/docs/rfcs/0007-advanced-developer-workflows.md @@ -1,4 +1,10 @@ -# Advanced developer workflows specification +--- +rfc: 0007 +title: Advanced developer workflows +status: Draft +--- + +# RFC 0007: Advanced developer workflows ## Goal @@ -66,13 +72,13 @@ the source command and preserves raw bytes until the upload boundary. ## Acceptance criteria -- Redaction tests include false positives, encoded secrets, large input, and +- [ ] 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 +- [ ] 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 +- [ ] 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. +- [ ] Integration helpers introduce no new server-side trust boundary. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md new file mode 100644 index 0000000..5107047 --- /dev/null +++ b/docs/rfcs/README.md @@ -0,0 +1,32 @@ +# Textbin RFCs + +RFCs are numbered product and engineering contracts. A Draft RFC describes a +proposal; it does not commit the project to delivery or delivery order. + +| RFC | Status | Covers | +|---|---|---| +| [0001: Administration](0001-administration.md) | Draft | Platform administrators, authorization, bootstrap, moderation, and internal views | +| [0002: Self-hosting documentation](0002-self-hosting-documentation.md) | Draft | Operator documentation for deploying and recovering the portable image | +| [0003: Paste discovery and API contract](0003-paste-discovery-and-api.md) | Draft | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | +| [0004: Hosted safety and operations](0004-hosted-safety-and-operations.md) | Draft | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | +| [0005: Workspace collaboration](0005-workspace-collaboration.md) | Draft | Invitations, defaults, workspace tokens, and CLI workflows | +| [0006: Commercial and enterprise](0006-commercial-and-enterprise.md) | Draft | Billing, paid limits, SSO, SCIM, domains, compliance, and support | +| [0007: Advanced developer workflows](0007-advanced-developer-workflows.md) | Draft | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | + +Run `./scripts/check-rfc-status.sh` to validate RFC numbering, metadata, status, +and acceptance-checklist progress. Supported statuses are `Draft`, `Accepted`, +`Active`, `Paused`, `Done`, `Rejected`, and `Superseded`. + +## 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 should work for both hosted and self-hosted installations unless a + specification explicitly identifies them as hosted-only. diff --git a/docs/specs/README.md b/docs/specs/README.md deleted file mode 100644 index 67fc9f7..0000000 --- a/docs/specs/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Textbin product specifications - -These documents are draft product and engineering contracts for work that has -not yet been scheduled. They define intended outcomes, authorization boundaries, -and acceptance criteria without committing to delivery order. - -| Specification | Covers | -|---|---| -| [Administration](administration.md) | Platform administrators, authorization, bootstrap, moderation, and internal views | -| [Self-hosting documentation](self-hosting-documentation.md) | Operator documentation for deploying and recovering the portable image | -| [Paste discovery and API contract](paste-discovery-and-api.md) | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | -| [Hosted safety and operations](hosted-safety-and-operations.md) | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | -| [Workspace collaboration](workspace-collaboration.md) | Invitations, defaults, workspace tokens, and CLI workflows | -| [Commercial and enterprise](commercial-and-enterprise.md) | Billing, paid limits, SSO, SCIM, domains, compliance, and support | -| [Advanced developer workflows](advanced-developer-workflows.md) | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | - -## 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 should work for both hosted and self-hosted installations unless a - specification explicitly identifies them as hosted-only. diff --git a/scripts/check-rfc-status.sh b/scripts/check-rfc-status.sh new file mode 100755 index 0000000..e50e0c2 --- /dev/null +++ b/scripts/check-rfc-status.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +rfcs_dir="${repo_root}/docs/rfcs" + +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_magenta=$'\033[35m' + color_dim=$'\033[2m' +else + color_reset="" + color_bold="" + color_red="" + color_green="" + color_yellow="" + color_blue="" + color_magenta="" + color_dim="" +fi + +if [[ ! -d "${rfcs_dir}" ]]; then + printf "%sRFC directory not found:%s %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 + exit 1 +fi + +colorize_status() { + local status="$1" + local padded="$2" + + case "${status}" in + Draft) printf "%s%s%s" "${color_blue}" "${padded}" "${color_reset}" ;; + Accepted | Active) printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" ;; + Paused) printf "%s%s%s" "${color_magenta}" "${padded}" "${color_reset}" ;; + Done) printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" ;; + Rejected | Superseded) printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" ;; + *) printf "%s%s%s" "${color_red}" "${padded}" "${color_reset}" ;; + esac +} + +colorize_progress() { + local complete="$1" + local total="$2" + local padded="$3" + + if [[ "${complete}" -eq "${total}" ]]; then + printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" + elif [[ "${complete}" -eq 0 ]]; then + printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" + else + printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" + fi +} + +read_metadata() { + local rfc="$1" + + awk ' + BEGIN { + id = "" + title = "" + status = "" + line_nr = 0 + in_front_matter = 0 + } + { + line_nr++ + + if (line_nr == 1 && $0 == "---") { + in_front_matter = 1 + next + } + + if (in_front_matter) { + if ($0 == "---") { + in_front_matter = 0 + next + } + + key = tolower($0) + value = $0 + sub(/^[^:]+:[[:space:]]*/, "", value) + + if (id == "" && key ~ /^rfc:[[:space:]]*/) { + id = value + } else if (title == "" && key ~ /^title:[[:space:]]*/) { + title = value + } else if (status == "" && key ~ /^status:[[:space:]]*/) { + status = value + } + } + } + END { printf "%s\t%s\t%s\n", id, title, status } + ' "${rfc}" +} + +read_checklist_counts() { + local rfc="$1" + + awk ' + BEGIN { complete = 0; total = 0 } + /^([[:space:]]*[-+*]|[[:space:]]*[0-9]+\.)[[:space:]]+\[[Xx ]\]/ { + total++ + if ($0 ~ /\[[Xx]\]/) { + complete++ + } + } + END { printf "%d %d\n", complete, total } + ' "${rfc}" +} + +printf "%s%-4s %-10s %-9s %s%s\n" "${color_bold}" "RFC" "Status" "Progress" "Title" "${color_reset}" +printf "%s%-4s %-10s %-9s %s%s\n" "${color_dim}" "----" "------" "--------" "------------------------------" "${color_reset}" + +failures=0 +found=0 +declare -A seen_ids=() + +shopt -s nullglob +rfcs=("${rfcs_dir}"/*.md) +shopt -u nullglob + +if [[ "${#rfcs[@]}" -eq 0 ]]; then + printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 + exit 1 +fi + +mapfile -t rfcs < <(printf '%s\n' "${rfcs[@]}" | sort -V) + +for rfc in "${rfcs[@]}"; do + filename="$(basename "${rfc}")" + + if [[ "${filename}" == "README.md" ]]; then + continue + fi + + found=1 + + if [[ ! "${filename}" =~ ^([0-9]{4})-[a-z0-9]+(-[a-z0-9]+)*\.md$ ]]; then + printf "%sinvalid RFC filename%s: %s\n" "${color_red}" "${color_reset}" "${filename}" >&2 + failures=$((failures + 1)) + continue + fi + + filename_id="${BASH_REMATCH[1]}" + metadata="$(read_metadata "${rfc}")" + id="${metadata%%$'\t'*}" + remainder="${metadata#*$'\t'}" + title="${remainder%%$'\t'*}" + status="${remainder#*$'\t'}" + + if [[ -z "${id}" ]]; then + id="(missing)" + failures=$((failures + 1)) + elif [[ "${id}" != "${filename_id}" ]]; then + printf " %sRFC number mismatch%s in %s: frontmatter has %s\n" "${color_red}" "${color_reset}" "${filename}" "${id}" >&2 + failures=$((failures + 1)) + elif [[ -n "${seen_ids[${id}]:-}" ]]; then + printf " %sduplicate RFC number%s in %s and %s\n" "${color_red}" "${color_reset}" "${seen_ids[${id}]}" "${filename}" >&2 + failures=$((failures + 1)) + else + seen_ids["${id}"]="${filename}" + fi + + if [[ -z "${title}" ]]; then + title="(missing title)" + failures=$((failures + 1)) + fi + + if [[ -z "${status}" ]]; then + status="(missing)" + failures=$((failures + 1)) + fi + + counts="$(read_checklist_counts "${rfc}")" + complete_count="${counts%% *}" + total_count="${counts##* }" + + status_field="$(printf "%-10s" "${status}")" + progress_field="$(printf "%-9s" "${complete_count}/${total_count}")" + status_text="$(colorize_status "${status}" "${status_field}")" + progress_text="$(colorize_progress "${complete_count}" "${total_count}" "${progress_field}")" + + printf "%-4s %s %s %s\n" "${filename_id}" "${status_text}" "${progress_text}" "${title}" + + case "${status}" in + Draft | Accepted | Active | Paused | Done | Rejected | Superseded) ;; + *) + printf " %sinvalid status%s in %s: %s\n" "${color_red}" "${color_reset}" "${filename}" "${status}" >&2 + failures=$((failures + 1)) + ;; + esac + + if [[ "${total_count}" -eq 0 ]]; then + printf " %sacceptance checklist is missing%s in %s\n" "${color_red}" "${color_reset}" "${filename}" >&2 + failures=$((failures + 1)) + elif [[ "${complete_count}" -eq "${total_count}" && "${status}" != "Done" && "${status}" != "Rejected" && "${status}" != "Superseded" ]]; then + printf " %sexpected Done, Rejected, or Superseded%s in %s: all checklist items are done\n" "${color_red}" "${color_reset}" "${filename}" >&2 + failures=$((failures + 1)) + elif [[ "${complete_count}" -lt "${total_count}" && "${status}" == "Done" ]]; then + printf " %sexpected an unfinished status%s in %s: checklist still has open items\n" "${color_red}" "${color_reset}" "${filename}" >&2 + failures=$((failures + 1)) + fi +done + +if [[ "${found}" -eq 0 ]]; then + printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 + exit 1 +fi + +if [[ "${failures}" -gt 0 ]]; then + echo + printf "%sRFC status check failed%s with %s issue(s).\n" "${color_red}" "${color_reset}" "${failures}" >&2 + exit 1 +fi + +echo +printf "%sRFC status check passed.%s\n" "${color_green}" "${color_reset}" From de275792691dddcc9891144a405ca363234ceb47 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 12:32:42 -0700 Subject: [PATCH 3/9] docs: remove commercial RFC --- ...d => 0006-advanced-developer-workflows.md} | 4 +- docs/rfcs/0006-commercial-and-enterprise.md | 70 ------------------- docs/rfcs/README.md | 3 +- 3 files changed, 3 insertions(+), 74 deletions(-) rename docs/rfcs/{0007-advanced-developer-workflows.md => 0006-advanced-developer-workflows.md} (98%) delete mode 100644 docs/rfcs/0006-commercial-and-enterprise.md diff --git a/docs/rfcs/0007-advanced-developer-workflows.md b/docs/rfcs/0006-advanced-developer-workflows.md similarity index 98% rename from docs/rfcs/0007-advanced-developer-workflows.md rename to docs/rfcs/0006-advanced-developer-workflows.md index 059d88e..39872ae 100644 --- a/docs/rfcs/0007-advanced-developer-workflows.md +++ b/docs/rfcs/0006-advanced-developer-workflows.md @@ -1,10 +1,10 @@ --- -rfc: 0007 +rfc: 0006 title: Advanced developer workflows status: Draft --- -# RFC 0007: Advanced developer workflows +# RFC 0006: Advanced developer workflows ## Goal diff --git a/docs/rfcs/0006-commercial-and-enterprise.md b/docs/rfcs/0006-commercial-and-enterprise.md deleted file mode 100644 index 2ab68ae..0000000 --- a/docs/rfcs/0006-commercial-and-enterprise.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -rfc: 0006 -title: Commercial and enterprise -status: Draft ---- - -# RFC 0006: Commercial and enterprise - -## Goal - -Add hosted commercial capabilities only after the stable API, administration, -and hosted-safety foundations exist. - -## Billing and higher limits - -Billing is organization-based. A subscription controls entitlements such as -paste size, storage, retention, member count, and API-token count. Provider -webhooks are authenticated, idempotent, persisted before processing, and safe -to replay. Billing status never becomes an authorization substitute for -organization roles. - -Downgrades do not delete content immediately. They block new over-limit actions -and provide a documented grace policy. Exact plans, prices, provider, tax -handling, grace periods, and quotas require a separate commercial decision. - -## Enterprise identity - -OIDC is implemented before SAML unless customer demand changes the order. -Domain discovery does not automatically merge accounts. Linking an external -identity requires authenticated proof and preserves a recovery path. - -SCIM provisioning is organization-scoped, uses independently revocable bearer -credentials, and handles retries idempotently. Deprovisioning removes managed -organization access without deleting the underlying user or unrelated -memberships. - -## Custom domains - -Custom domains require verified DNS control, certificate issuance and renewal, -collision prevention, and canonical URL behavior. Authentication cookies remain -bound to the primary Textbin domain unless a dedicated custom-domain auth model -is specified. - -## Audit and compliance exports - -Organization audit coverage expands to paste lifecycle, sharing-policy changes, -membership and invitation changes, token lifecycle, workspace settings, and -enterprise identity events. Reads of paste content are not logged by default; -enabling them requires a privacy and volume policy. - -Exports are generated asynchronously, encrypted at rest, short-lived, and -available only to authorized organization owners. They include a manifest and -checksums and never include credential material. - -## Support and SLA packaging - -Support tiers define channels, support hours, severity levels, response targets, -service exclusions, and status-communication behavior. Availability promises -are not published until monitoring and incident response can measure them. - -## Acceptance criteria - -- [ ] Entitlements are deterministic, auditable, and resilient to duplicated or - reordered billing webhooks. -- [ ] Billing failures cannot grant authority or expose another organization's data. -- [ ] OIDC, SAML, and SCIM identities are tenant-scoped and cannot be claimed by an - unrelated organization. -- [ ] Custom-domain verification and certificate renewal fail closed. -- [ ] Exports are authorized at download time and expire automatically. -- [ ] Published SLA measurements match the actual monitoring definition. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 5107047..5314ab1 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -10,8 +10,7 @@ proposal; it does not commit the project to delivery or delivery order. | [0003: Paste discovery and API contract](0003-paste-discovery-and-api.md) | Draft | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | | [0004: Hosted safety and operations](0004-hosted-safety-and-operations.md) | Draft | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | | [0005: Workspace collaboration](0005-workspace-collaboration.md) | Draft | Invitations, defaults, workspace tokens, and CLI workflows | -| [0006: Commercial and enterprise](0006-commercial-and-enterprise.md) | Draft | Billing, paid limits, SSO, SCIM, domains, compliance, and support | -| [0007: Advanced developer workflows](0007-advanced-developer-workflows.md) | Draft | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | +| [0006: Advanced developer workflows](0006-advanced-developer-workflows.md) | Draft | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | Run `./scripts/check-rfc-status.sh` to validate RFC numbering, metadata, status, and acceptance-checklist progress. Supported statuses are `Draft`, `Accepted`, From ea62a2c4a08d7960919d23991fcf886726367d94 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 17:59:40 -0700 Subject: [PATCH 4/9] fix: harden RFC status validation --- .github/workflows/elixir.yml | 3 + Makefile | 1 + docs/rfcs/README.md | 16 +- scripts/check-rfc-status-test.sh | 133 ++++++++++++ scripts/check-rfc-status.sh | 355 +++++++++++++++++-------------- 5 files changed, 342 insertions(+), 166 deletions(-) create mode 100755 scripts/check-rfc-status-test.sh diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 43e309e..a5a8f10 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -25,6 +25,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Check RFCs + run: make check-rfcs + - name: Setup Elixir uses: erlef/setup-beam@v1 with: diff --git a/Makefile b/Makefile index 035e12a..042aacc 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ lint: ex-lint rs-lint .PHONY: check-rfcs check-rfcs: ./scripts/check-rfc-status.sh + ./scripts/check-rfc-status-test.sh .PHONY: ex-lint ex-lint: diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 5314ab1..126380a 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -3,14 +3,14 @@ RFCs are numbered product and engineering contracts. A Draft RFC describes a proposal; it does not commit the project to delivery or delivery order. -| RFC | Status | Covers | -|---|---|---| -| [0001: Administration](0001-administration.md) | Draft | Platform administrators, authorization, bootstrap, moderation, and internal views | -| [0002: Self-hosting documentation](0002-self-hosting-documentation.md) | Draft | Operator documentation for deploying and recovering the portable image | -| [0003: Paste discovery and API contract](0003-paste-discovery-and-api.md) | Draft | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | -| [0004: Hosted safety and operations](0004-hosted-safety-and-operations.md) | Draft | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | -| [0005: Workspace collaboration](0005-workspace-collaboration.md) | Draft | Invitations, defaults, workspace tokens, and CLI workflows | -| [0006: Advanced developer workflows](0006-advanced-developer-workflows.md) | Draft | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | +| RFC | Covers | +|---|---| +| [0001: Administration](0001-administration.md) | Platform administrators, authorization, bootstrap, moderation, and internal views | +| [0002: Self-hosting documentation](0002-self-hosting-documentation.md) | Operator documentation for deploying and recovering the portable image | +| [0003: Paste discovery and API contract](0003-paste-discovery-and-api.md) | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | +| [0004: Hosted safety and operations](0004-hosted-safety-and-operations.md) | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | +| [0005: Workspace collaboration](0005-workspace-collaboration.md) | Invitations, defaults, workspace tokens, and CLI workflows | +| [0006: Advanced developer workflows](0006-advanced-developer-workflows.md) | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | Run `./scripts/check-rfc-status.sh` to validate RFC numbering, metadata, status, and acceptance-checklist progress. Supported statuses are `Draft`, `Accepted`, diff --git a/scripts/check-rfc-status-test.sh b/scripts/check-rfc-status-test.sh new file mode 100755 index 0000000..20b4d7e --- /dev/null +++ b/scripts/check-rfc-status-test.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +checker="${repo_root}/scripts/check-rfc-status.sh" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/textbin-rfc-check.XXXXXX")" +rfcs_dir="${test_root}/rfcs" +output="${test_root}/output" + +trap 'rm -rf "${test_root}"' EXIT + +reset_fixtures() { + rm -rf "${rfcs_dir}" + mkdir -p "${rfcs_dir}" + printf '# Test RFCs\n' >"${rfcs_dir}/README.md" +} + +run_success() { + if ! NO_COLOR=1 RFCS_DIR="${rfcs_dir}" bash "${checker}" >"${output}" 2>&1; then + cat "${output}" >&2 + printf 'expected RFC checker to pass\n' >&2 + exit 1 + fi +} + +run_failure() { + local expected="$1" + + if NO_COLOR=1 RFCS_DIR="${rfcs_dir}" bash "${checker}" >"${output}" 2>&1; then + cat "${output}" >&2 + printf 'expected RFC checker to fail with: %s\n' "${expected}" >&2 + exit 1 + fi + + if ! grep -Fq "${expected}" "${output}"; then + cat "${output}" >&2 + printf 'RFC checker failure did not include: %s\n' "${expected}" >&2 + exit 1 + fi +} + +reset_fixtures +cat >"${rfcs_dir}/0001-valid.md" <<'EOF' +--- +rfc: 0001 +title: Valid RFC +status: Draft +--- + +## Acceptance criteria + +- [ ] It works. +EOF +run_success + +reset_fixtures +cat >"${rfcs_dir}/0001-unclosed.md" <<'EOF' +--- +rfc: 0001 +title: Unclosed RFC +status: Draft + +## Acceptance criteria + +- [ ] It works. +EOF +run_failure "frontmatter is not closed" + +reset_fixtures +cat >"${rfcs_dir}/0001-duplicate-metadata.md" <<'EOF' +--- +rfc: 0001 +rfc: 0001 +title: Duplicate metadata +status: Draft +--- + +## Acceptance criteria + +- [ ] It works. +EOF +run_failure "exactly one non-empty rfc value" + +reset_fixtures +cat >"${rfcs_dir}/0001-no-acceptance.md" <<'EOF' +--- +rfc: 0001 +title: No acceptance section +status: Draft +--- + +## Work items + +- [ ] This unrelated task must not count. +EOF +run_failure "exactly one Acceptance criteria section" + +reset_fixtures +cat >"${rfcs_dir}/0001-complete.md" <<'EOF' +--- +rfc: 0001 +title: Complete acceptance criteria +status: Active +--- + +## Work items + +- [ ] This unrelated task must not affect progress. + +## Acceptance criteria + +- [x] It works. +EOF +run_failure "all acceptance criteria are done" + +reset_fixtures +for slug in first second; do + cat >"${rfcs_dir}/0001-${slug}.md" <<'EOF' +--- +rfc: 0001 +title: Duplicate number +status: Draft +--- + +## Acceptance criteria + +- [ ] It works. +EOF +done +run_failure "duplicate RFC number" + +printf 'RFC checker tests passed.\n' diff --git a/scripts/check-rfc-status.sh b/scripts/check-rfc-status.sh index e50e0c2..70d3f9f 100755 --- a/scripts/check-rfc-status.sh +++ b/scripts/check-rfc-status.sh @@ -3,115 +3,163 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -rfcs_dir="${repo_root}/docs/rfcs" - -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_magenta=$'\033[35m' - color_dim=$'\033[2m' +rfcs_dir="${RFCS_DIR:-${repo_root}/docs/rfcs}" + +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_magenta=$'\033[35m' + color_dim=$'\033[2m' else - color_reset="" - color_bold="" - color_red="" - color_green="" - color_yellow="" - color_blue="" - color_magenta="" - color_dim="" + color_reset="" + color_bold="" + color_red="" + color_green="" + color_yellow="" + color_blue="" + color_magenta="" + color_dim="" fi if [[ ! -d "${rfcs_dir}" ]]; then - printf "%sRFC directory not found:%s %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 - exit 1 + printf "%sRFC directory not found:%s %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 + exit 1 fi colorize_status() { - local status="$1" - local padded="$2" - - case "${status}" in - Draft) printf "%s%s%s" "${color_blue}" "${padded}" "${color_reset}" ;; - Accepted | Active) printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" ;; - Paused) printf "%s%s%s" "${color_magenta}" "${padded}" "${color_reset}" ;; - Done) printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" ;; - Rejected | Superseded) printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" ;; - *) printf "%s%s%s" "${color_red}" "${padded}" "${color_reset}" ;; - esac + local status="$1" + local padded="$2" + + case "${status}" in + Draft) printf "%s%s%s" "${color_blue}" "${padded}" "${color_reset}" ;; + Accepted | Active) printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" ;; + Paused) printf "%s%s%s" "${color_magenta}" "${padded}" "${color_reset}" ;; + Done) printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" ;; + Rejected | Superseded) printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" ;; + *) printf "%s%s%s" "${color_red}" "${padded}" "${color_reset}" ;; + esac } colorize_progress() { - local complete="$1" - local total="$2" - local padded="$3" - - if [[ "${complete}" -eq "${total}" ]]; then - printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" - elif [[ "${complete}" -eq 0 ]]; then - printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" - else - printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" - fi + local complete="$1" + local total="$2" + local padded="$3" + + if [[ "${complete}" -eq "${total}" ]]; then + printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" + elif [[ "${complete}" -eq 0 ]]; then + printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" + else + printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" + fi } -read_metadata() { - local rfc="$1" +read_rfc() { + local rfc="$1" + local filename="$2" + + awk -v filename="${filename}" ' + function problem(message) { + printf " %s: %s\n", filename, message > "/dev/stderr" + errors++ + } - awk ' BEGIN { id = "" title = "" status = "" - line_nr = 0 - in_front_matter = 0 + in_frontmatter = 0 + frontmatter_closed = 0 + acceptance_sections = 0 + in_acceptance = 0 + complete = 0 + total = 0 + errors = 0 } - { - line_nr++ - if (line_nr == 1 && $0 == "---") { - in_front_matter = 1 + { + if (NR == 1) { + if ($0 == "---") { + in_frontmatter = 1 + } else { + problem("frontmatter must start on the first line") + } next } - if (in_front_matter) { - if ($0 == "---") { - in_front_matter = 0 - next - } + if (in_frontmatter && $0 == "---") { + frontmatter_closed = 1 + in_frontmatter = 0 + next + } + if (in_frontmatter) { key = tolower($0) value = $0 sub(/^[^:]+:[[:space:]]*/, "", value) - if (id == "" && key ~ /^rfc:[[:space:]]*/) { - id = value - } else if (title == "" && key ~ /^title:[[:space:]]*/) { - title = value - } else if (status == "" && key ~ /^status:[[:space:]]*/) { - status = value + if (index(value, "\t") > 0) { + problem("frontmatter values must not contain tabs") + gsub(/\t/, " ", value) + } + + if (key ~ /^rfc:[[:space:]]*/) { + id_count++ + if (id_count == 1) id = value + } else if (key ~ /^title:[[:space:]]*/) { + title_count++ + if (title_count == 1) title = value + } else if (key ~ /^status:[[:space:]]*/) { + status_count++ + if (status_count == 1) status = value } + next + } + + if ($0 ~ /^## Acceptance criteria[[:space:]]*$/) { + acceptance_sections++ + in_acceptance = 1 + next + } + + if ($0 ~ /^##[[:space:]]/) { + in_acceptance = 0 + next + } + + if (in_acceptance && $0 ~ /^([[:space:]]*[-+*]|[[:space:]]*[0-9]+\.)[[:space:]]+\[[Xx ]\]/) { + total++ + if ($0 ~ /\[[Xx]\]/) complete++ } } - END { printf "%s\t%s\t%s\n", id, title, status } - ' "${rfc}" -} -read_checklist_counts() { - local rfc="$1" + END { + if (!frontmatter_closed) problem("frontmatter is not closed") - awk ' - BEGIN { complete = 0; total = 0 } - /^([[:space:]]*[-+*]|[[:space:]]*[0-9]+\.)[[:space:]]+\[[Xx ]\]/ { - total++ - if ($0 ~ /\[[Xx]\]/) { - complete++ + if (id_count != 1 || id == "") { + problem("frontmatter must contain exactly one non-empty rfc value") } + + if (title_count != 1 || title == "") { + problem("frontmatter must contain exactly one non-empty title value") + } + + if (status_count != 1 || status == "") { + problem("frontmatter must contain exactly one non-empty status value") + } + + if (acceptance_sections != 1) { + problem("document must contain exactly one Acceptance criteria section") + } else if (total == 0) { + problem("Acceptance criteria must contain at least one checklist item") + } + + printf "%s\t%s\t%s\t%d\t%d\t%d\n", id, title, status, complete, total, errors } - END { printf "%d %d\n", complete, total } ' "${rfc}" } @@ -120,104 +168,95 @@ printf "%s%-4s %-10s %-9s %s%s\n" "${color_dim}" "----" "------" "--------" " failures=0 found=0 -declare -A seen_ids=() +seen_ids="" shopt -s nullglob rfcs=("${rfcs_dir}"/*.md) shopt -u nullglob if [[ "${#rfcs[@]}" -eq 0 ]]; then - printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 - exit 1 + printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 + exit 1 fi -mapfile -t rfcs < <(printf '%s\n' "${rfcs[@]}" | sort -V) - for rfc in "${rfcs[@]}"; do - filename="$(basename "${rfc}")" - - if [[ "${filename}" == "README.md" ]]; then - continue - fi - - found=1 - - if [[ ! "${filename}" =~ ^([0-9]{4})-[a-z0-9]+(-[a-z0-9]+)*\.md$ ]]; then - printf "%sinvalid RFC filename%s: %s\n" "${color_red}" "${color_reset}" "${filename}" >&2 - failures=$((failures + 1)) - continue - fi - - filename_id="${BASH_REMATCH[1]}" - metadata="$(read_metadata "${rfc}")" - id="${metadata%%$'\t'*}" - remainder="${metadata#*$'\t'}" - title="${remainder%%$'\t'*}" - status="${remainder#*$'\t'}" - - if [[ -z "${id}" ]]; then - id="(missing)" - failures=$((failures + 1)) - elif [[ "${id}" != "${filename_id}" ]]; then - printf " %sRFC number mismatch%s in %s: frontmatter has %s\n" "${color_red}" "${color_reset}" "${filename}" "${id}" >&2 - failures=$((failures + 1)) - elif [[ -n "${seen_ids[${id}]:-}" ]]; then - printf " %sduplicate RFC number%s in %s and %s\n" "${color_red}" "${color_reset}" "${seen_ids[${id}]}" "${filename}" >&2 - failures=$((failures + 1)) - else - seen_ids["${id}"]="${filename}" - fi - - if [[ -z "${title}" ]]; then - title="(missing title)" - failures=$((failures + 1)) - fi - - if [[ -z "${status}" ]]; then - status="(missing)" - failures=$((failures + 1)) - fi - - counts="$(read_checklist_counts "${rfc}")" - complete_count="${counts%% *}" - total_count="${counts##* }" - - status_field="$(printf "%-10s" "${status}")" - progress_field="$(printf "%-9s" "${complete_count}/${total_count}")" - status_text="$(colorize_status "${status}" "${status_field}")" - progress_text="$(colorize_progress "${complete_count}" "${total_count}" "${progress_field}")" - - printf "%-4s %s %s %s\n" "${filename_id}" "${status_text}" "${progress_text}" "${title}" - - case "${status}" in - Draft | Accepted | Active | Paused | Done | Rejected | Superseded) ;; - *) - printf " %sinvalid status%s in %s: %s\n" "${color_red}" "${color_reset}" "${filename}" "${status}" >&2 - failures=$((failures + 1)) - ;; - esac - - if [[ "${total_count}" -eq 0 ]]; then - printf " %sacceptance checklist is missing%s in %s\n" "${color_red}" "${color_reset}" "${filename}" >&2 - failures=$((failures + 1)) - elif [[ "${complete_count}" -eq "${total_count}" && "${status}" != "Done" && "${status}" != "Rejected" && "${status}" != "Superseded" ]]; then - printf " %sexpected Done, Rejected, or Superseded%s in %s: all checklist items are done\n" "${color_red}" "${color_reset}" "${filename}" >&2 - failures=$((failures + 1)) - elif [[ "${complete_count}" -lt "${total_count}" && "${status}" == "Done" ]]; then - printf " %sexpected an unfinished status%s in %s: checklist still has open items\n" "${color_red}" "${color_reset}" "${filename}" >&2 - failures=$((failures + 1)) - fi + filename="$(basename "${rfc}")" + + if [[ "${filename}" == "README.md" ]]; then + continue + fi + + found=1 + + if [[ ! "${filename}" =~ ^([0-9]{4})-[a-z0-9]+(-[a-z0-9]+)*\.md$ ]]; then + printf "%sinvalid RFC filename%s: %s\n" "${color_red}" "${color_reset}" "${filename}" >&2 + failures=$((failures + 1)) + continue + fi + + filename_id="${BASH_REMATCH[1]}" + parsed="$(read_rfc "${rfc}" "${filename}")" + id="${parsed%%$'\t'*}" + remainder="${parsed#*$'\t'}" + title="${remainder%%$'\t'*}" + remainder="${remainder#*$'\t'}" + status="${remainder%%$'\t'*}" + remainder="${remainder#*$'\t'}" + complete_count="${remainder%%$'\t'*}" + remainder="${remainder#*$'\t'}" + total_count="${remainder%%$'\t'*}" + parser_errors="${remainder#*$'\t'}" + failures=$((failures + parser_errors)) + + if [[ -n "${id}" && "${id}" != "${filename_id}" ]]; then + printf " %sRFC number mismatch%s in %s: frontmatter has %s\n" "${color_red}" "${color_reset}" "${filename}" "${id}" >&2 + failures=$((failures + 1)) + fi + + if [[ -n "${id}" ]]; then + case "${seen_ids}" in + *"|${id}|"*) + printf " %sduplicate RFC number%s in %s: %s\n" "${color_red}" "${color_reset}" "${filename}" "${id}" >&2 + failures=$((failures + 1)) + ;; + *) seen_ids="${seen_ids}|${id}|" ;; + esac + fi + + status_field="$(printf "%-10s" "${status:-\(missing\)}")" + progress_field="$(printf "%-9s" "${complete_count}/${total_count}")" + status_text="$(colorize_status "${status}" "${status_field}")" + progress_text="$(colorize_progress "${complete_count}" "${total_count}" "${progress_field}")" + + printf "%-4s %s %s %s\n" "${filename_id}" "${status_text}" "${progress_text}" "${title:-\(missing title\)}" + + case "${status}" in + Draft | Accepted | Active | Paused | Done | Rejected | Superseded) ;; + "") ;; + *) + printf " %sinvalid status%s in %s: %s\n" "${color_red}" "${color_reset}" "${filename}" "${status}" >&2 + failures=$((failures + 1)) + ;; + esac + + if [[ "${total_count}" -gt 0 && "${complete_count}" -eq "${total_count}" && "${status}" != "Done" && "${status}" != "Rejected" && "${status}" != "Superseded" ]]; then + printf " %sexpected Done, Rejected, or Superseded%s in %s: all acceptance criteria are done\n" "${color_red}" "${color_reset}" "${filename}" >&2 + failures=$((failures + 1)) + elif [[ "${complete_count}" -lt "${total_count}" && "${status}" == "Done" ]]; then + printf " %sexpected an unfinished status%s in %s: acceptance criteria remain open\n" "${color_red}" "${color_reset}" "${filename}" >&2 + failures=$((failures + 1)) + fi done if [[ "${found}" -eq 0 ]]; then - printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 - exit 1 + printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 + exit 1 fi if [[ "${failures}" -gt 0 ]]; then - echo - printf "%sRFC status check failed%s with %s issue(s).\n" "${color_red}" "${color_reset}" "${failures}" >&2 - exit 1 + echo + printf "%sRFC status check failed%s with %s issue(s).\n" "${color_red}" "${color_reset}" "${failures}" >&2 + exit 1 fi echo From 2fe5bf1e97f2db41dea76eda775381219c6e4127 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 17:59:46 -0700 Subject: [PATCH 5/9] chore: remove Docker Compose workflow --- .github/workflows/elixir.yml | 10 ++++++-- Makefile | 8 ------- README.md | 14 +++++------ docker-compose.yml | 45 ------------------------------------ flake.nix | 1 - test/textbin/config_test.exs | 13 ----------- 6 files changed, 15 insertions(+), 76 deletions(-) delete mode 100644 docker-compose.yml diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index a5a8f10..fed6c65 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -155,7 +155,13 @@ jobs: - name: Start SeaweedFS if: matrix.storage-backend == 's3' run: | - docker compose up -d object-storage + s3_config="$GITHUB_WORKSPACE/config/seaweedfs_s3.json" + docker run --detach --name object-storage --hostname object-storage \ + --publish 8333:8333 \ + --volume "$s3_config:/etc/seaweedfs/s3.json:ro" \ + chrislusf/seaweedfs:4.40 \ + server -s3 -dir=/data -ip=object-storage -ip.bind=0.0.0.0 \ + -master.volumeSizeLimitMB=1024 -s3.config=/etc/seaweedfs/s3.json for attempt in $(seq 1 30); do if curl --silent --show-error \ @@ -166,7 +172,7 @@ jobs: sleep 1 done - docker compose logs object-storage + docker logs object-storage exit 1 - name: Run tests with ${{ matrix.storage-backend }} storage diff --git a/Makefile b/Makefile index 042aacc..31145dc 100644 --- a/Makefile +++ b/Makefile @@ -33,14 +33,6 @@ rs-lint: rs-fmt: cargo fmt --check -.PHONY: up -up: - docker compose up -d - -.PHONY: down -down: - docker compose stop - .PHONY: migrate migrate: mix ecto.migration diff --git a/README.md b/README.md index 7e66f2c..e2d8852 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ To start your Phoenix server: Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. -## Docker Compose +## Development dependencies -Start Postgres for local development: +Textbin requires PostgreSQL. Development and test configuration default to a +local server on port `5433`; use the `DATABASE_*` environment variables when +your server listens elsewhere. -```sh -docker compose up db -``` - -Then run `mix setup` and `mix phx.server` locally as usual. +Development defaults to S3-compatible paste storage on port `8333`. To develop +without an object-storage service, set `TEXTBIN_STORAGE_BACKEND=local` and +`TEXTBIN_STORAGE_PATH` to a writable local directory before starting Phoenix. Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 5641d90..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,45 +0,0 @@ -services: - db: - image: postgres:17-alpine - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: textbin_dev - ports: - - "5433:5432" - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d textbin_dev"] - interval: 5s - timeout: 5s - retries: 10 - networks: - - textbin - - object-storage: - image: chrislusf/seaweedfs:4.40 - command: - - server - - -s3 - - -dir=/data - - -ip=object-storage - - -ip.bind=0.0.0.0 - - -master.volumeSizeLimitMB=1024 - - -s3.config=/etc/seaweedfs/s3.json - ports: - - "8333:8333" - volumes: - - object-storage-data:/data - - ./config/seaweedfs_s3.json:/etc/seaweedfs/s3.json:ro - restart: unless-stopped - networks: - - textbin - -volumes: - postgres-data: - object-storage-data: - -networks: - textbin: - driver: bridge diff --git a/flake.nix b/flake.nix index 744566b..6330891 100644 --- a/flake.nix +++ b/flake.nix @@ -55,7 +55,6 @@ # Tools watchman - docker-compose yamllint pkg-config openssl diff --git a/test/textbin/config_test.exs b/test/textbin/config_test.exs index c45fb05..4e4a576 100644 --- a/test/textbin/config_test.exs +++ b/test/textbin/config_test.exs @@ -18,19 +18,6 @@ defmodule Textbin.ConfigTest do on_exit(fn -> Enum.each(previous, fn {name, value} -> restore_env(name, value) end) end) end - test "the test database default port matches Docker Compose" do - database_port = System.get_env("DATABASE_PORT") - System.delete_env("DATABASE_PORT") - - on_exit(fn -> restore_env("DATABASE_PORT", database_port) end) - - config = Config.Reader.read!("config/test.exs", env: :test) - test_port = config[:textbin][Textbin.Repo][:port] - docker_compose = File.read!("docker-compose.yml") - - assert docker_compose =~ ~s(- "#{test_port}:5432") - end - test "Phoenix and Cargo release versions stay synchronized" do mix_project = File.read!("mix.exs") cargo_workspace = File.read!("Cargo.toml") From 6718a0c79a20667340f62b76956708c9222e421e Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 21:56:49 -0700 Subject: [PATCH 6/9] docs: adopt Requests for Discussion format --- .github/workflows/elixir.yml | 4 +- Makefile | 8 +- docs/rfcs/0002-self-hosting-documentation.md | 62 ----- docs/rfcs/README.md | 31 --- .../0001/README.adoc | 71 +++-- rfd/0002/README.adoc | 58 ++++ .../0003/README.adoc | 45 ++- .../0004/README.adoc | 37 ++- .../0005/README.adoc | 33 ++- .../0006/README.adoc | 43 ++- rfd/README.md | 76 +++++ scripts/check-rfc-status-test.sh | 133 --------- scripts/check-rfc-status.sh | 263 ------------------ scripts/check-rfd-status-test.sh | 93 +++++++ scripts/check-rfd-status.sh | 213 ++++++++++++++ 15 files changed, 558 insertions(+), 612 deletions(-) delete mode 100644 docs/rfcs/0002-self-hosting-documentation.md delete mode 100644 docs/rfcs/README.md rename docs/rfcs/0001-administration.md => rfd/0001/README.adoc (62%) create mode 100644 rfd/0002/README.adoc rename docs/rfcs/0003-paste-discovery-and-api.md => rfd/0003/README.adoc (70%) rename docs/rfcs/0004-hosted-safety-and-operations.md => rfd/0004/README.adoc (79%) rename docs/rfcs/0005-workspace-collaboration.md => rfd/0005/README.adoc (78%) rename docs/rfcs/0006-advanced-developer-workflows.md => rfd/0006/README.adoc (76%) create mode 100644 rfd/README.md delete mode 100755 scripts/check-rfc-status-test.sh delete mode 100755 scripts/check-rfc-status.sh create mode 100755 scripts/check-rfd-status-test.sh create mode 100755 scripts/check-rfd-status.sh diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index fed6c65..60f03bc 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -25,8 +25,8 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Check RFCs - run: make check-rfcs + - name: Check RFDs + run: make check-rfds - name: Setup Elixir uses: erlef/setup-beam@v1 diff --git a/Makefile b/Makefile index 31145dc..285d3c0 100644 --- a/Makefile +++ b/Makefile @@ -16,10 +16,10 @@ rs-test: .PHONY: lint lint: ex-lint rs-lint -.PHONY: check-rfcs -check-rfcs: - ./scripts/check-rfc-status.sh - ./scripts/check-rfc-status-test.sh +.PHONY: check-rfds +check-rfds: + ./scripts/check-rfd-status.sh + ./scripts/check-rfd-status-test.sh .PHONY: ex-lint ex-lint: diff --git a/docs/rfcs/0002-self-hosting-documentation.md b/docs/rfcs/0002-self-hosting-documentation.md deleted file mode 100644 index 77103f7..0000000 --- a/docs/rfcs/0002-self-hosting-documentation.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -rfc: 0002 -title: Self-hosting documentation -status: Draft ---- - -# RFC 0002: 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. - -The repository's Compose configuration may continue to support local -development dependencies. It is not a supported self-hosted topology. - -## 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. - -## Acceptance criteria - -- [ ] 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/docs/rfcs/README.md b/docs/rfcs/README.md deleted file mode 100644 index 126380a..0000000 --- a/docs/rfcs/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Textbin RFCs - -RFCs are numbered product and engineering contracts. A Draft RFC describes a -proposal; it does not commit the project to delivery or delivery order. - -| RFC | Covers | -|---|---| -| [0001: Administration](0001-administration.md) | Platform administrators, authorization, bootstrap, moderation, and internal views | -| [0002: Self-hosting documentation](0002-self-hosting-documentation.md) | Operator documentation for deploying and recovering the portable image | -| [0003: Paste discovery and API contract](0003-paste-discovery-and-api.md) | Metadata, viewer polish, filters, search, JSON output, OpenAPI, and SDKs | -| [0004: Hosted safety and operations](0004-hosted-safety-and-operations.md) | Limits, rate limiting, abuse controls, telemetry, tracing, and backups | -| [0005: Workspace collaboration](0005-workspace-collaboration.md) | Invitations, defaults, workspace tokens, and CLI workflows | -| [0006: Advanced developer workflows](0006-advanced-developer-workflows.md) | Redaction, CI helpers, bundles, diffs, migration, encryption, and integrations | - -Run `./scripts/check-rfc-status.sh` to validate RFC numbering, metadata, status, -and acceptance-checklist progress. Supported statuses are `Draft`, `Accepted`, -`Active`, `Paused`, `Done`, `Rejected`, and `Superseded`. - -## 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 should work for both hosted and self-hosted installations unless a - specification explicitly identifies them as hosted-only. diff --git a/docs/rfcs/0001-administration.md b/rfd/0001/README.adoc similarity index 62% rename from docs/rfcs/0001-administration.md rename to rfd/0001/README.adoc index 83ca9ba..e2e681e 100644 --- a/docs/rfcs/0001-administration.md +++ b/rfd/0001/README.adoc @@ -1,33 +1,32 @@ ---- -rfc: 0001 -title: Administration -status: Draft ---- +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: process, software, security -# RFC 0001: Administration += RFD 1 Administration -## Goal +== 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 +== 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 +* 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. +* 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 +== Authentication and authorization The admin LiveViews belong inside the existing `live_session :require_authenticated_user` and @@ -44,40 +43,40 @@ 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 +== 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: -1. fail if the user does not exist or is not confirmed; -2. lock and update the selected account transactionally; -3. report whether authority was granted or already present; and -4. append an immutable platform audit event naming the actor as the bootstrap +. 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 +== 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. +* 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 +== 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 @@ -89,17 +88,17 @@ 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. -## Acceptance criteria +== Acceptance criteria -- [ ] A non-admin cannot mount an admin route or obtain admin data by calling a +* [ ] 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 +* [ ] Organization owners and admins have no platform authority unless separately granted it. -- [ ] An operator can grant the first platform administrator from a release without +* [ ] An operator can grant the first platform administrator from a release without manipulating the database manually. -- [ ] Every privilege change, suspension, restoration, and administrative deletion +* [ ] 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 +* [ ] 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/0002/README.adoc b/rfd/0002/README.adoc new file mode 100644 index 0000000..fee45d1 --- /dev/null +++ b/rfd/0002/README.adoc @@ -0,0 +1,58 @@ +: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. + +== Acceptance criteria + +* [ ] 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/docs/rfcs/0003-paste-discovery-and-api.md b/rfd/0003/README.adoc similarity index 70% rename from docs/rfcs/0003-paste-discovery-and-api.md rename to rfd/0003/README.adoc index 0d1134e..bb2d050 100644 --- a/docs/rfcs/0003-paste-discovery-and-api.md +++ b/rfd/0003/README.adoc @@ -1,17 +1,16 @@ ---- -rfc: 0003 -title: Paste discovery and API contract -status: Draft ---- +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, api -# RFC 0003: Paste discovery and API contract += RFD 3 Paste discovery and API contract -## Goal +== 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 +== 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 @@ -21,7 +20,7 @@ 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 +== 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 @@ -29,15 +28,15 @@ active subset of `list`, not a separate persistence model. Search is introduced in this order: -1. exact/prefix metadata search over title and filename; -2. PostgreSQL full-text search for eligible text paste bodies; -3. tags and advanced filters only after their data model is specified. +. 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 +== 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 @@ -48,14 +47,14 @@ 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 +== 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 and clients OpenAPI covers authentication, token management, paste CRUD, list/recent/search, raw content, organizations, workspaces, memberships, and invitations. The @@ -66,14 +65,14 @@ 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. -## Acceptance criteria +== Acceptance criteria -- [ ] Viewer metadata, readable sizes, and line anchors work for anonymous shared +* [ ] 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 +* [ ] 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. +* [ ] 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/docs/rfcs/0004-hosted-safety-and-operations.md b/rfd/0004/README.adoc similarity index 79% rename from docs/rfcs/0004-hosted-safety-and-operations.md rename to rfd/0004/README.adoc index 6d613f6..c7142aa 100644 --- a/docs/rfcs/0004-hosted-safety-and-operations.md +++ b/rfd/0004/README.adoc @@ -1,17 +1,16 @@ ---- -rfc: 0004 -title: Hosted safety and operations -status: Draft ---- +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, security, metrics -# RFC 0004: Hosted safety and operations += RFD 4 Hosted safety and operations -## Goal +== Goal Protect a public Textbin installation from accidental overload and abuse while providing enough telemetry and recovery procedures to operate it safely. -## Upload limits +== 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 @@ -21,7 +20,7 @@ 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 +== Rate limiting Initial rate limits cover authentication attempts, token creation, paste creation, raw reads, and expensive search operations. Keys include normalized @@ -33,7 +32,7 @@ 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 +== 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. @@ -45,7 +44,7 @@ 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 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 @@ -59,7 +58,7 @@ 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 backups Hosted environments automate encrypted PostgreSQL and blob backups, record their completion, and alert on missed schedules. Restore drills run on an @@ -67,14 +66,14 @@ 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. -## Acceptance criteria +== Acceptance criteria -- [ ] Limits are enforced consistently across browser, raw API, JSON API, and CLI +* [ ] Limits are enforced consistently across browser, raw API, JSON API, and CLI traffic. -- [ ] Tests cover proxy trust, distributed-limit behavior, retry headers, and key +* [ ] Tests cover proxy trust, distributed-limit behavior, retry headers, and key isolation. -- [ ] Reports can be submitted without exposing reporter identity publicly and can +* [ ] 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. +* [ ] 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/docs/rfcs/0005-workspace-collaboration.md b/rfd/0005/README.adoc similarity index 78% rename from docs/rfcs/0005-workspace-collaboration.md rename to rfd/0005/README.adoc index f59df0c..0f7699f 100644 --- a/docs/rfcs/0005-workspace-collaboration.md +++ b/rfd/0005/README.adoc @@ -1,17 +1,16 @@ ---- -rfc: 0005 -title: Workspace collaboration -status: Draft ---- +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, process -# RFC 0005: Workspace collaboration += RFD 5 Workspace collaboration -## Goal +== Goal Complete team workflows around the existing organization, workspace, role, and workspace-owned paste model. -## Invitations +== Invitations Organization owners and admins can invite an email address to an organization with an initial organization role. Workspace owners can invite an existing @@ -23,7 +22,7 @@ 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 +== Workspace defaults Each workspace can define default visibility and expiration plus an optional maximum expiration. Creation applies defaults server-side when values are @@ -34,7 +33,7 @@ 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 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, @@ -45,7 +44,7 @@ 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 +== 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 @@ -56,15 +55,15 @@ 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. -## Acceptance criteria +== Acceptance criteria -- [ ] Invitation token theft alone is insufficient when the authenticated email +* [ ] Invitation token theft alone is insufficient when the authenticated email does not match the invitation. -- [ ] Concurrent acceptance, revocation, and role changes preserve one membership +* [ ] Concurrent acceptance, revocation, and role changes preserve one membership and current authorization policy. -- [ ] Workspace defaults are enforced identically for browser, user-token, and +* [ ] Workspace defaults are enforced identically for browser, user-token, and workspace-token creation. -- [ ] Workspace tokens cannot cross workspace boundaries or exercise ungranted +* [ ] Workspace tokens cannot cross workspace boundaries or exercise ungranted scopes. -- [ ] CLI profile selection never redirects a command to another workspace without +* [ ] CLI profile selection never redirects a command to another workspace without explicit user action. diff --git a/docs/rfcs/0006-advanced-developer-workflows.md b/rfd/0006/README.adoc similarity index 76% rename from docs/rfcs/0006-advanced-developer-workflows.md rename to rfd/0006/README.adoc index 39872ae..d6ad613 100644 --- a/docs/rfcs/0006-advanced-developer-workflows.md +++ b/rfd/0006/README.adoc @@ -1,17 +1,16 @@ ---- -rfc: 0006 -title: Advanced developer workflows -status: Draft ---- +:authors: Darwin D Wu +:state: discussion +:discussion: https://github.com/chaba-dev/textbin/pull/54 +:labels: software, debug, security -# RFC 0006: Advanced developer workflows += RFD 6 Advanced developer workflows -## Goal +== Goal Add higher-level debugging workflows without weakening the core guarantees for content integrity, authorization, predictable CLI output, and portability. -## Secret scanning and redaction +== 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 @@ -22,27 +21,27 @@ 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 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 +== 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 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 +== Import, export, and migration Exports use a versioned manifest, checksums, and streaming content files. Imports validate the complete manifest and report conflicts before mutation. @@ -52,7 +51,7 @@ 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 +== Client-side encryption Encryption occurs before upload using a versioned, authenticated-encryption envelope. Servers store ciphertext and minimal algorithm metadata and cannot @@ -63,22 +62,22 @@ server storage. Key derivation, recipient sharing, recovery, browser decryption, and URL-fragment key UX require a dedicated cryptographic design review before implementation. -## Local integrations +== 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. -## Acceptance criteria +== Acceptance criteria -- [ ] Redaction tests include false positives, encoded secrets, large input, and +* [ ] 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 +* [ ] 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 +* [ ] 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. +* [ ] Integration helpers introduce no new server-side trust boundary. diff --git a/rfd/README.md b/rfd/README.md new file mode 100644 index 0000000..82ee0d4 --- /dev/null +++ b/rfd/README.md @@ -0,0 +1,76 @@ +# 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. + +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. +Checklists may help organize an RFD, but they do not determine its state. + +## Lifecycle + +Reserve the next unused four-digit number and create `rfd/NNNN/README.adoc` on a +branch. 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-rfc-status-test.sh b/scripts/check-rfc-status-test.sh deleted file mode 100755 index 20b4d7e..0000000 --- a/scripts/check-rfc-status-test.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -checker="${repo_root}/scripts/check-rfc-status.sh" -test_root="$(mktemp -d "${TMPDIR:-/tmp}/textbin-rfc-check.XXXXXX")" -rfcs_dir="${test_root}/rfcs" -output="${test_root}/output" - -trap 'rm -rf "${test_root}"' EXIT - -reset_fixtures() { - rm -rf "${rfcs_dir}" - mkdir -p "${rfcs_dir}" - printf '# Test RFCs\n' >"${rfcs_dir}/README.md" -} - -run_success() { - if ! NO_COLOR=1 RFCS_DIR="${rfcs_dir}" bash "${checker}" >"${output}" 2>&1; then - cat "${output}" >&2 - printf 'expected RFC checker to pass\n' >&2 - exit 1 - fi -} - -run_failure() { - local expected="$1" - - if NO_COLOR=1 RFCS_DIR="${rfcs_dir}" bash "${checker}" >"${output}" 2>&1; then - cat "${output}" >&2 - printf 'expected RFC checker to fail with: %s\n' "${expected}" >&2 - exit 1 - fi - - if ! grep -Fq "${expected}" "${output}"; then - cat "${output}" >&2 - printf 'RFC checker failure did not include: %s\n' "${expected}" >&2 - exit 1 - fi -} - -reset_fixtures -cat >"${rfcs_dir}/0001-valid.md" <<'EOF' ---- -rfc: 0001 -title: Valid RFC -status: Draft ---- - -## Acceptance criteria - -- [ ] It works. -EOF -run_success - -reset_fixtures -cat >"${rfcs_dir}/0001-unclosed.md" <<'EOF' ---- -rfc: 0001 -title: Unclosed RFC -status: Draft - -## Acceptance criteria - -- [ ] It works. -EOF -run_failure "frontmatter is not closed" - -reset_fixtures -cat >"${rfcs_dir}/0001-duplicate-metadata.md" <<'EOF' ---- -rfc: 0001 -rfc: 0001 -title: Duplicate metadata -status: Draft ---- - -## Acceptance criteria - -- [ ] It works. -EOF -run_failure "exactly one non-empty rfc value" - -reset_fixtures -cat >"${rfcs_dir}/0001-no-acceptance.md" <<'EOF' ---- -rfc: 0001 -title: No acceptance section -status: Draft ---- - -## Work items - -- [ ] This unrelated task must not count. -EOF -run_failure "exactly one Acceptance criteria section" - -reset_fixtures -cat >"${rfcs_dir}/0001-complete.md" <<'EOF' ---- -rfc: 0001 -title: Complete acceptance criteria -status: Active ---- - -## Work items - -- [ ] This unrelated task must not affect progress. - -## Acceptance criteria - -- [x] It works. -EOF -run_failure "all acceptance criteria are done" - -reset_fixtures -for slug in first second; do - cat >"${rfcs_dir}/0001-${slug}.md" <<'EOF' ---- -rfc: 0001 -title: Duplicate number -status: Draft ---- - -## Acceptance criteria - -- [ ] It works. -EOF -done -run_failure "duplicate RFC number" - -printf 'RFC checker tests passed.\n' diff --git a/scripts/check-rfc-status.sh b/scripts/check-rfc-status.sh deleted file mode 100755 index 70d3f9f..0000000 --- a/scripts/check-rfc-status.sh +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -rfcs_dir="${RFCS_DIR:-${repo_root}/docs/rfcs}" - -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_magenta=$'\033[35m' - color_dim=$'\033[2m' -else - color_reset="" - color_bold="" - color_red="" - color_green="" - color_yellow="" - color_blue="" - color_magenta="" - color_dim="" -fi - -if [[ ! -d "${rfcs_dir}" ]]; then - printf "%sRFC directory not found:%s %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 - exit 1 -fi - -colorize_status() { - local status="$1" - local padded="$2" - - case "${status}" in - Draft) printf "%s%s%s" "${color_blue}" "${padded}" "${color_reset}" ;; - Accepted | Active) printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" ;; - Paused) printf "%s%s%s" "${color_magenta}" "${padded}" "${color_reset}" ;; - Done) printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" ;; - Rejected | Superseded) printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" ;; - *) printf "%s%s%s" "${color_red}" "${padded}" "${color_reset}" ;; - esac -} - -colorize_progress() { - local complete="$1" - local total="$2" - local padded="$3" - - if [[ "${complete}" -eq "${total}" ]]; then - printf "%s%s%s" "${color_green}" "${padded}" "${color_reset}" - elif [[ "${complete}" -eq 0 ]]; then - printf "%s%s%s" "${color_dim}" "${padded}" "${color_reset}" - else - printf "%s%s%s" "${color_yellow}" "${padded}" "${color_reset}" - fi -} - -read_rfc() { - local rfc="$1" - local filename="$2" - - awk -v filename="${filename}" ' - function problem(message) { - printf " %s: %s\n", filename, message > "/dev/stderr" - errors++ - } - - BEGIN { - id = "" - title = "" - status = "" - in_frontmatter = 0 - frontmatter_closed = 0 - acceptance_sections = 0 - in_acceptance = 0 - complete = 0 - total = 0 - errors = 0 - } - - { - if (NR == 1) { - if ($0 == "---") { - in_frontmatter = 1 - } else { - problem("frontmatter must start on the first line") - } - next - } - - if (in_frontmatter && $0 == "---") { - frontmatter_closed = 1 - in_frontmatter = 0 - next - } - - if (in_frontmatter) { - key = tolower($0) - value = $0 - sub(/^[^:]+:[[:space:]]*/, "", value) - - if (index(value, "\t") > 0) { - problem("frontmatter values must not contain tabs") - gsub(/\t/, " ", value) - } - - if (key ~ /^rfc:[[:space:]]*/) { - id_count++ - if (id_count == 1) id = value - } else if (key ~ /^title:[[:space:]]*/) { - title_count++ - if (title_count == 1) title = value - } else if (key ~ /^status:[[:space:]]*/) { - status_count++ - if (status_count == 1) status = value - } - next - } - - if ($0 ~ /^## Acceptance criteria[[:space:]]*$/) { - acceptance_sections++ - in_acceptance = 1 - next - } - - if ($0 ~ /^##[[:space:]]/) { - in_acceptance = 0 - next - } - - if (in_acceptance && $0 ~ /^([[:space:]]*[-+*]|[[:space:]]*[0-9]+\.)[[:space:]]+\[[Xx ]\]/) { - total++ - if ($0 ~ /\[[Xx]\]/) complete++ - } - } - - END { - if (!frontmatter_closed) problem("frontmatter is not closed") - - if (id_count != 1 || id == "") { - problem("frontmatter must contain exactly one non-empty rfc value") - } - - if (title_count != 1 || title == "") { - problem("frontmatter must contain exactly one non-empty title value") - } - - if (status_count != 1 || status == "") { - problem("frontmatter must contain exactly one non-empty status value") - } - - if (acceptance_sections != 1) { - problem("document must contain exactly one Acceptance criteria section") - } else if (total == 0) { - problem("Acceptance criteria must contain at least one checklist item") - } - - printf "%s\t%s\t%s\t%d\t%d\t%d\n", id, title, status, complete, total, errors - } - ' "${rfc}" -} - -printf "%s%-4s %-10s %-9s %s%s\n" "${color_bold}" "RFC" "Status" "Progress" "Title" "${color_reset}" -printf "%s%-4s %-10s %-9s %s%s\n" "${color_dim}" "----" "------" "--------" "------------------------------" "${color_reset}" - -failures=0 -found=0 -seen_ids="" - -shopt -s nullglob -rfcs=("${rfcs_dir}"/*.md) -shopt -u nullglob - -if [[ "${#rfcs[@]}" -eq 0 ]]; then - printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 - exit 1 -fi - -for rfc in "${rfcs[@]}"; do - filename="$(basename "${rfc}")" - - if [[ "${filename}" == "README.md" ]]; then - continue - fi - - found=1 - - if [[ ! "${filename}" =~ ^([0-9]{4})-[a-z0-9]+(-[a-z0-9]+)*\.md$ ]]; then - printf "%sinvalid RFC filename%s: %s\n" "${color_red}" "${color_reset}" "${filename}" >&2 - failures=$((failures + 1)) - continue - fi - - filename_id="${BASH_REMATCH[1]}" - parsed="$(read_rfc "${rfc}" "${filename}")" - id="${parsed%%$'\t'*}" - remainder="${parsed#*$'\t'}" - title="${remainder%%$'\t'*}" - remainder="${remainder#*$'\t'}" - status="${remainder%%$'\t'*}" - remainder="${remainder#*$'\t'}" - complete_count="${remainder%%$'\t'*}" - remainder="${remainder#*$'\t'}" - total_count="${remainder%%$'\t'*}" - parser_errors="${remainder#*$'\t'}" - failures=$((failures + parser_errors)) - - if [[ -n "${id}" && "${id}" != "${filename_id}" ]]; then - printf " %sRFC number mismatch%s in %s: frontmatter has %s\n" "${color_red}" "${color_reset}" "${filename}" "${id}" >&2 - failures=$((failures + 1)) - fi - - if [[ -n "${id}" ]]; then - case "${seen_ids}" in - *"|${id}|"*) - printf " %sduplicate RFC number%s in %s: %s\n" "${color_red}" "${color_reset}" "${filename}" "${id}" >&2 - failures=$((failures + 1)) - ;; - *) seen_ids="${seen_ids}|${id}|" ;; - esac - fi - - status_field="$(printf "%-10s" "${status:-\(missing\)}")" - progress_field="$(printf "%-9s" "${complete_count}/${total_count}")" - status_text="$(colorize_status "${status}" "${status_field}")" - progress_text="$(colorize_progress "${complete_count}" "${total_count}" "${progress_field}")" - - printf "%-4s %s %s %s\n" "${filename_id}" "${status_text}" "${progress_text}" "${title:-\(missing title\)}" - - case "${status}" in - Draft | Accepted | Active | Paused | Done | Rejected | Superseded) ;; - "") ;; - *) - printf " %sinvalid status%s in %s: %s\n" "${color_red}" "${color_reset}" "${filename}" "${status}" >&2 - failures=$((failures + 1)) - ;; - esac - - if [[ "${total_count}" -gt 0 && "${complete_count}" -eq "${total_count}" && "${status}" != "Done" && "${status}" != "Rejected" && "${status}" != "Superseded" ]]; then - printf " %sexpected Done, Rejected, or Superseded%s in %s: all acceptance criteria are done\n" "${color_red}" "${color_reset}" "${filename}" >&2 - failures=$((failures + 1)) - elif [[ "${complete_count}" -lt "${total_count}" && "${status}" == "Done" ]]; then - printf " %sexpected an unfinished status%s in %s: acceptance criteria remain open\n" "${color_red}" "${color_reset}" "${filename}" >&2 - failures=$((failures + 1)) - fi -done - -if [[ "${found}" -eq 0 ]]; then - printf "%sno RFC files found%s in %s\n" "${color_red}" "${color_reset}" "${rfcs_dir}" >&2 - exit 1 -fi - -if [[ "${failures}" -gt 0 ]]; then - echo - printf "%sRFC status check failed%s with %s issue(s).\n" "${color_red}" "${color_reset}" "${failures}" >&2 - exit 1 -fi - -echo -printf "%sRFC status check passed.%s\n" "${color_green}" "${color_reset}" diff --git a/scripts/check-rfd-status-test.sh b/scripts/check-rfd-status-test.sh new file mode 100755 index 0000000..3184aa9 --- /dev/null +++ b/scripts/check-rfd-status-test.sh @@ -0,0 +1,93 @@ +#!/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" + + mkdir -p "${rfd_root}/0001" + cat >"${rfd_root}/0001/README.adoc" < +:state: ${state} +:discussion: ${discussion} +:labels: software, process + += RFD 1 Valid RFD +EOF +} + +reset_fixtures +write_valid_rfd discussion https://example.com/pull/1 +run_success + +reset_fixtures +write_valid_rfd prediscussion "" +run_success + +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..f4c95df --- /dev/null +++ b/scripts/check-rfd-status.sh @@ -0,0 +1,213 @@ +#!/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 + + 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}" From 53ecd1dbee8190c21c2f609bd0130bc85ac76ff7 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 22:45:07 -0700 Subject: [PATCH 7/9] docs: separate RFD implementation checklists --- rfd/0001/IMPLEMENTATION.org | 16 ++++++++++ rfd/0001/README.adoc | 18 +++-------- rfd/0002/IMPLEMENTATION.org | 13 ++++++++ rfd/0002/README.adoc | 15 +++------ rfd/0003/IMPLEMENTATION.org | 13 ++++++++ rfd/0003/README.adoc | 15 +++------ rfd/0004/IMPLEMENTATION.org | 13 ++++++++ rfd/0004/README.adoc | 15 +++------ rfd/0005/IMPLEMENTATION.org | 14 +++++++++ rfd/0005/README.adoc | 16 +++------- rfd/0006/IMPLEMENTATION.org | 14 +++++++++ rfd/0006/README.adoc | 16 +++------- rfd/README.md | 17 +++++++--- scripts/check-rfd-status-test.sh | 53 +++++++++++++++++++++++++++++++- scripts/check-rfd-status.sh | 46 +++++++++++++++++++++++++++ 15 files changed, 217 insertions(+), 77 deletions(-) create mode 100644 rfd/0001/IMPLEMENTATION.org create mode 100644 rfd/0002/IMPLEMENTATION.org create mode 100644 rfd/0003/IMPLEMENTATION.org create mode 100644 rfd/0004/IMPLEMENTATION.org create mode 100644 rfd/0005/IMPLEMENTATION.org create mode 100644 rfd/0006/IMPLEMENTATION.org 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 index e2e681e..0b1448e 100644 --- a/rfd/0001/README.adoc +++ b/rfd/0001/README.adoc @@ -88,17 +88,7 @@ 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. -== Acceptance criteria - -* [ ] 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. +== 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 index fee45d1..7cdf171 100644 --- a/rfd/0002/README.adoc +++ b/rfd/0002/README.adoc @@ -45,14 +45,7 @@ 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. -== Acceptance criteria - -* [ ] 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. +== 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 index bb2d050..3230a6f 100644 --- a/rfd/0003/README.adoc +++ b/rfd/0003/README.adoc @@ -65,14 +65,7 @@ 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. -== Acceptance criteria - -* [ ] 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. +== 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 index c7142aa..1177658 100644 --- a/rfd/0004/README.adoc +++ b/rfd/0004/README.adoc @@ -66,14 +66,7 @@ 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. -== Acceptance criteria - -* [ ] 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. +== 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 index 0f7699f..5df825f 100644 --- a/rfd/0005/README.adoc +++ b/rfd/0005/README.adoc @@ -55,15 +55,7 @@ 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. -== Acceptance criteria - -* [ ] 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. +== 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 index d6ad613..7c342f5 100644 --- a/rfd/0006/README.adoc +++ b/rfd/0006/README.adoc @@ -69,15 +69,7 @@ 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. -== Acceptance criteria - -* [ ] 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. +== 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 index 82ee0d4..fc98e64 100644 --- a/rfd/README.md +++ b/rfd/README.md @@ -32,6 +32,11 @@ 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. @@ -46,14 +51,16 @@ checker regression fixtures. The usual path is `prediscussion` or `ideation` to `discussion`, then `published`, and eventually `committed`. `abandoned` is an off-ramp at any stage. -Checklists may help organize an RFD, but they do not determine its state. +Implementation checklist progress does not determine the RFD's state. ## Lifecycle -Reserve the next unused four-digit number and create `rfd/NNNN/README.adoc` on a -branch. 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. +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 diff --git a/scripts/check-rfd-status-test.sh b/scripts/check-rfd-status-test.sh index 3184aa9..5974c4d 100755 --- a/scripts/check-rfd-status-test.sh +++ b/scripts/check-rfd-status-test.sh @@ -43,6 +43,17 @@ run_failure() { 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" <"${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 @@ -60,9 +96,24 @@ write_valid_rfd discussion https://example.com/pull/1 run_success reset_fixtures -write_valid_rfd prediscussion "" +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" diff --git a/scripts/check-rfd-status.sh b/scripts/check-rfd-status.sh index f4c95df..2f9a339 100755 --- a/scripts/check-rfd-status.sh +++ b/scripts/check-rfd-status.sh @@ -182,6 +182,52 @@ for entry in "${entries[@]}"; do 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'}" From aa90fd8054f85c906cb0fb2d7e59872f07c6a964 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 23:16:30 -0700 Subject: [PATCH 8/9] Revert "chore: remove Docker Compose workflow" This reverts commit 2fe5bf1e97f2db41dea76eda775381219c6e4127. --- .github/workflows/elixir.yml | 10 ++------ Makefile | 8 +++++++ README.md | 14 +++++------ docker-compose.yml | 45 ++++++++++++++++++++++++++++++++++++ flake.nix | 1 + test/textbin/config_test.exs | 13 +++++++++++ 6 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 docker-compose.yml diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 60f03bc..ced8876 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -155,13 +155,7 @@ jobs: - name: Start SeaweedFS if: matrix.storage-backend == 's3' run: | - s3_config="$GITHUB_WORKSPACE/config/seaweedfs_s3.json" - docker run --detach --name object-storage --hostname object-storage \ - --publish 8333:8333 \ - --volume "$s3_config:/etc/seaweedfs/s3.json:ro" \ - chrislusf/seaweedfs:4.40 \ - server -s3 -dir=/data -ip=object-storage -ip.bind=0.0.0.0 \ - -master.volumeSizeLimitMB=1024 -s3.config=/etc/seaweedfs/s3.json + docker compose up -d object-storage for attempt in $(seq 1 30); do if curl --silent --show-error \ @@ -172,7 +166,7 @@ jobs: sleep 1 done - docker logs object-storage + docker compose logs object-storage exit 1 - name: Run tests with ${{ matrix.storage-backend }} storage diff --git a/Makefile b/Makefile index 285d3c0..f2a56a4 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,14 @@ rs-lint: rs-fmt: cargo fmt --check +.PHONY: up +up: + docker compose up -d + +.PHONY: down +down: + docker compose stop + .PHONY: migrate migrate: mix ecto.migration diff --git a/README.md b/README.md index e2d8852..7e66f2c 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ To start your Phoenix server: Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. -## Development dependencies +## Docker Compose -Textbin requires PostgreSQL. Development and test configuration default to a -local server on port `5433`; use the `DATABASE_*` environment variables when -your server listens elsewhere. +Start Postgres for local development: -Development defaults to S3-compatible paste storage on port `8333`. To develop -without an object-storage service, set `TEXTBIN_STORAGE_BACKEND=local` and -`TEXTBIN_STORAGE_PATH` to a writable local directory before starting Phoenix. +```sh +docker compose up db +``` + +Then run `mix setup` and `mix phx.server` locally as usual. Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5641d90 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: textbin_dev + ports: + - "5433:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d textbin_dev"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - textbin + + object-storage: + image: chrislusf/seaweedfs:4.40 + command: + - server + - -s3 + - -dir=/data + - -ip=object-storage + - -ip.bind=0.0.0.0 + - -master.volumeSizeLimitMB=1024 + - -s3.config=/etc/seaweedfs/s3.json + ports: + - "8333:8333" + volumes: + - object-storage-data:/data + - ./config/seaweedfs_s3.json:/etc/seaweedfs/s3.json:ro + restart: unless-stopped + networks: + - textbin + +volumes: + postgres-data: + object-storage-data: + +networks: + textbin: + driver: bridge diff --git a/flake.nix b/flake.nix index 6330891..744566b 100644 --- a/flake.nix +++ b/flake.nix @@ -55,6 +55,7 @@ # Tools watchman + docker-compose yamllint pkg-config openssl diff --git a/test/textbin/config_test.exs b/test/textbin/config_test.exs index 4e4a576..c45fb05 100644 --- a/test/textbin/config_test.exs +++ b/test/textbin/config_test.exs @@ -18,6 +18,19 @@ defmodule Textbin.ConfigTest do on_exit(fn -> Enum.each(previous, fn {name, value} -> restore_env(name, value) end) end) end + test "the test database default port matches Docker Compose" do + database_port = System.get_env("DATABASE_PORT") + System.delete_env("DATABASE_PORT") + + on_exit(fn -> restore_env("DATABASE_PORT", database_port) end) + + config = Config.Reader.read!("config/test.exs", env: :test) + test_port = config[:textbin][Textbin.Repo][:port] + docker_compose = File.read!("docker-compose.yml") + + assert docker_compose =~ ~s(- "#{test_port}:5432") + end + test "Phoenix and Cargo release versions stay synchronized" do mix_project = File.read!("mix.exs") cargo_workspace = File.read!("Cargo.toml") From 6e49b41a76a66d53194f92da3ae6830c1ca7e83e Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Fri, 21 Aug 2026 23:20:13 -0700 Subject: [PATCH 9/9] chore: quiet RFD check commands --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index f2a56a4..216eaa2 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,8 @@ lint: ex-lint rs-lint .PHONY: check-rfds check-rfds: - ./scripts/check-rfd-status.sh - ./scripts/check-rfd-status-test.sh + @./scripts/check-rfd-status.sh + @./scripts/check-rfd-status-test.sh .PHONY: ex-lint ex-lint: