Skip to content

Latest commit

 

History

History
368 lines (256 loc) · 21.7 KB

File metadata and controls

368 lines (256 loc) · 21.7 KB

Contributing to Pluralscape

Contributions from everyone are welcome. This project is community-driven by design. The predecessor app suffered from being a one-person effort, and Pluralscape is built as the opposite.

Getting Started

  1. Fork the repository
  2. Create a feature branch (git checkout -b feat/your-feature)
  3. Write tests first, then implement (see Development Methodology below)
  4. Ensure all tests pass and coverage thresholds are met
  5. Open a pull request against main

Pull Request Process

  • Use the PR template — it includes a review checklist covering privacy, offline behavior, accessibility, and data safety
  • Keep PRs focused — one feature or fix per PR
  • Include tests for new functionality (written before the implementation — see TDD)
  • Update documentation if behavior changes
  • List any deferred work in the "Deferred Items" section of the PR template

Required pre-PR gates

Every PR must pass these checks locally before it is opened; CI enforces them as well:

  • pnpm format — Prettier formatting
  • pnpm lint — ESLint with zero warnings (--max-warnings 0)
  • pnpm typecheck — TypeScript strict type-checking
  • pnpm types:check-sot — Types-as-SoT parity gate (types + Drizzle + Zod + OpenAPI-Wire parity; CI-enforced, any drift fails)
  • pnpm test — unit + integration suites
  • pnpm openapi:check — REST spec reconciler; the checked-in docs/openapi.yaml must match generated output
  • pnpm trpc:parity — REST route / tRPC procedure parity (see Adding API Endpoints)

The /verify command runs the full suite (format, lint, typecheck, unit, integration, e2e) in one shot.

Pre-release code hygiene

Pluralscape is pre-production. Remove deprecated code outright. No backwards-compatible aliases, re-exports, or @deprecated shims. Delete old symbols in the same PR that introduces the replacement.

Commit Conventions

We use Conventional Commits:

type(scope): description

Types: feat, fix, refactor, docs, test, chore, style, perf, ci, build

  • Imperative mood, 72 characters max, no trailing period
  • Subject must start lowercase — commitlint extends @commitlint/config-conventional, which enforces sentence/start-case rejection. feat: Add foo fails the hook; feat: add foo passes.
  • Branch naming: type/short-desc (50 chars max)

Architecture Decisions

Major technical decisions are documented as ADRs in docs/adr/. If your contribution involves a significant architectural choice, please write an ADR using the template at docs/adr/000-template.md.

Zero-knowledge rule

The server must never perform master-key operations or handle raw user-secret material. All envelope encryption, bucket key derivation, and recovery-package assembly happen client-side. See ADR 013 — API Auth & Encryption and the master-key migration in PR #439 for the authoritative boundary. PRs that route master-key material through the server will be rejected.

Work Tracking (Beans)

Work items are tracked as beans (a CLI issue tracker) with files committed under .beans/ alongside the code they describe. Full conventions live in docs/work-tracking.md.

  • Before committing, mark the bean as completed and add a ## Summary of Changes section describing what shipped.
  • Include the bean file in the same commit as the code change so history stays self-contained.
  • Use the existing prefixes (ps-, api-, mobile-, db-, crypto-, sync-, types-, client-, infra-). Epics are containers — break them into feature / task / bug children.

Development Methodology — TDD

This project follows Test-Driven Development (TDD). All new code should be written test-first.

The cycle

  1. Red — Write a test that describes the behavior you want. Run it — it should fail.
  2. Green — Write the simplest code that makes the test pass.
  3. Refactor — Clean up duplication, improve naming, restructure — while keeping tests green.

In practice

  • New features: Start by writing tests for the public API or user-facing behavior. Then build the implementation to satisfy them.
  • Bug fixes: First write a test that reproduces the bug. Confirm it fails. Then fix the code and confirm the test passes.
  • Refactors: Ensure existing tests cover the code you're changing. If they don't, add tests first, then refactor.

Why TDD?

Pluralscape handles sensitive personal data (identity, fronting, journaling) with privacy and encryption guarantees. TDD helps the project:

  • Catch regressions in privacy and encryption logic before they ship
  • Build confidence in offline-first sync behavior through repeatable tests
  • Maintain coverage naturally, without chasing metrics after the fact
  • Design cleaner APIs by thinking about usage before implementation

What if I'm not used to TDD?

That's okay. It's a practice, not a gatekeeping requirement. If you're new to TDD:

  • Start small: write one test before one function
  • It's fine to spike (prototype without tests) to explore an approach, then delete the spike and rebuild test-first
  • Ask for help in Discussions if you're stuck

PRs without tests for new functionality will be asked to add them. PRs that follow the TDD cycle (test commits before implementation commits) are appreciated but not strictly required. What matters is that tests exist and cover the behavior.

Code Standards

  • Code quality matters, not the tools used to write it. AI-assisted contributions are welcome
  • Write tests for new functionality
  • Follow existing patterns and conventions in the codebase
  • Ensure accessibility (WCAG compliance) for any UI changes
  • Privacy defaults to maximum restriction (fail-closed)
  • Use community terminology, not clinical language. Say "system" not "patient", "member" not "personality", "fronting" not "presenting"

TypeScript Strictness

Strict typing is enforced. The following are not permitted:

  • No as any — find the correct type or fix the type upstream
  • No as unknown as T — this is a double-cast escape hatch; restructure the code instead
  • No @ts-ignore — if the compiler is wrong, use @ts-expect-error with a comment explaining why
  • No @ts-expect-error without a justification comment — explain the specific compiler limitation
  • No eslint-disable comments — fix the violation or raise a discussion about the rule
  • No non-null assertions (!) — use narrowing, early returns, or explicit checks instead
  • No var — use const by default, let only when reassignment is necessary

as never is allowed only in exhaustive switch/match default cases to enforce compile-time exhaustiveness:

// correct usage of `as never`
switch (status) {
  case "fronting": ...
  case "co-fronting": ...
  default: {
    const _exhaustive: never = status;
    throw new Error(`Unhandled status: ${_exhaustive}`);
  }
}

Code Quality

  • Explicit return types on exported functions — inferred types are fine for internal/private functions
  • No floating promises — all promises must be awaited, returned, or explicitly voided with void
  • No swallowed errors — every catch must log, rethrow, or handle meaningfully. Empty catch blocks are not permitted
  • No console.log in production code — use structured logging. console.log is acceptable in scripts and tests only
  • Prefer early returns over deeply nested conditionals
  • Exhaustive pattern matching — all switch statements on union types must handle every case (use as never default)
  • No magic numbers/strings — extract to *.constants.ts files. Each package/domain has its own (e.g., crypto.constants.ts, middleware.constants.ts). The ESLint config disables no-magic-numbers for this glob. Every constant needs a JSDoc comment and should use numeric underscores for readability (86_400 not 86400). When adding a constant, extend the nearest existing *.constants.ts; only create a new file when entering a new domain.

Mobile Development

All interactive UI elements must have accessibility props (accessibilityLabel, accessibilityRole) with minimum 44x44pt touch targets.

For hook conventions, offline-first patterns, the provider tree architecture, platform abstraction, and a walkthrough of adding new features end-to-end, see the Mobile Developer Guide.

Adding or Modifying Encrypted Entities

packages/types is the single source of truth for every domain entity. Each encrypted entity exposes a six-link canonical chain in its module under packages/types/src/entities/:

  1. <Entity> — full decrypted domain shape
  2. <Entity>EncryptedFields — keys-union of fields encrypted client-side
  3. <Entity>EncryptedInput = Pick<<Entity>, <Entity>EncryptedFields> — what callers encrypt
  4. <Entity>ServerMetadata — Drizzle row (plaintext columns + encryptedData blob + any ServerInternal<…> fields)
  5. <Entity>Result = EncryptedWire<<Entity>ServerMetadata> — server JS-runtime response
  6. <Entity>Wire = Serialize<<Entity>Result> — JSON HTTP shape

When adding a new encrypted entity:

  1. Define all six types in packages/types/src/entities/<entity>.ts
  2. Add the entity to packages/types/src/__sot-manifest__.ts with full slots
  3. Add <Entity>EncryptedInputSchema to packages/validation/src/<entity>.ts and re-export from the validation index
  4. Add the Drizzle table; the parity tests will fail until InferSelectModel<…> matches <Entity>ServerMetadata
  5. Add the OpenAPI response schema; the G7 parity test (scripts/openapi-wire-parity.type-test.ts) will fail until components["schemas"]["<Entity>Response"] ≡ <Entity>Wire
  6. Add the client transform under packages/data/src/transforms/<entity>.ts (functions only — no local domain/wire/encrypted-input aliases). Reference packages/data/src/transforms/member.ts for the canonical shape.

pnpm types:check-sot runs all four parity gates sequentially. CI blocks on failure — drift at any layer fails the build. See ADR-023 for the full rationale, including the ServerInternal<T> and EncryptedBase64 brand conventions.

Adding API Endpoints

Every new API feature requires both a REST route and a tRPC procedure. The CI check (pnpm trpc:parity) enforces this.

Checklist:

  1. Add REST route in apps/api/src/routes/
  2. Add tRPC procedure in the matching router under apps/api/src/trpc/routers/
  3. Apply matching rate limit middleware on both (see table below)
  4. Apply matching auth level on both
  5. Import input validation from @pluralscape/validation — do not define inline Zod schemas
  6. Write unit tests for the procedure and route handler
  7. Write integration tests covering success, not-found, and unauthorized
  8. Run pnpm trpc:parity — must pass before opening a PR

If the new endpoint is REST-only by design (SSE, infrastructure), add an entry to apps/api/scripts/trpc-parity.config.ts with a documented reason.

Rate limit categories

Category Limit Use for
readDefault 60 req/min Standard read operations
readHeavy 30 req/min Expensive reads (reports, analytics)
write 60 req/min Standard create/update/delete
authHeavy 5 req/min Login, password reset, token exchange
authLight 20 req/min Session refresh, token validation
blobUpload 20 req/min File and photo uploads
auditQuery 10 req/min Audit log and delivery log reads
friendCodeRedeem 10 req/min Friend code redemption

Rate limit categories are defined in @pluralscape/types. Use the exact same category on the REST route and the tRPC procedure — the parity check flags mismatches.

Service file structure and size

Services live in apps/api/src/services/ and follow a per-verb-file layout — no barrel. Callers import from the specific verb file (e.g., services/member/create.js), not from a package index or sibling re-export.

  • Nest to match routes: a service nests under a parent iff the routes tree does. Mirror apps/api/src/routes/.
  • Verb-split at 300 LOC: any service ≥300 LOC must be split into services/<domain>/<verb>.ts files (create, queries, update, lifecycle, delete, etc.). Single-file peer modules are fine below that threshold.
  • Shared helpers: put shared types/helpers in services/<domain>/internal.ts only if consumed by ≥2 verb files. Single-consumer helpers stay local to the verb file.
  • No barrels: do not create services/<domain>/index.ts or a sibling services/<domain>.ts re-export. The moduleResolution: "Bundler" setting does not resolve ./foo.js./foo/index.ts by design, and barrels defeat the tree-shaking and static analysis benefits of explicit imports.
  • Hard cap: ESLint enforces max-lines: 450 on src/services/**/*.ts (skipping blanks/comments). If you hit it, split the file — do not raise the cap or add an override comment.

File-size ceilings (ESLint max-lines)

LOC ceilings are codified per area in tooling/eslint-config/loc-rules.js and enforced by pnpm lint (and pnpm lint:loc at the root). Counts are ESLint default — all lines including blanks and comments, matching wc -l. The rule is split, don't override: when you hit a cap, decompose the file. Never raise the cap or suppress the rule with a directive comment.

Area glob Cap
apps/api/src/routes/**/*.{ts,tsx} 200
apps/api/src/middleware/**/*.ts 200
apps/api/src/jobs/**/*.ts 200
apps/api/src/trpc/**/*.ts 350
apps/api/src/services/**/*.ts 450
apps/api/src/lib/**/*.ts 500
apps/api/src/ws/**/*.ts 500
apps/mobile/src/**/*.{ts,tsx} 500
apps/mobile/app/**/*.{ts,tsx} 400
packages/types/src/**/*.ts 450
packages/sync/src/**/*.ts 750
packages/queue/src/**/*.ts 500
packages/import-core/src/**/*.ts 500
Other packages/*/src/**/*.ts 500
**/*.constants.ts 300
Tests (**/*.test.ts, __tests__/**) 750

Tier B caps (api/lib, api/ws, mobile/src, sync, queue, import-core) are lockstep. They currently sit at the value above with a small buffer over the largest in-tree file, and ratchet downward as the tree shrinks. Tier A caps (routes, middleware, trpc, services, types) are target standards. Both are hard errors in CI; treat the cap as a design signal, not a number to negotiate with.

RLS-context wrappers

Every database query against an RLS-protected table must go through the wrapper helpers in apps/api/src/lib/rls-context.ts:

  • withTenantRead(systemId, fn) — runs fn with app.current_system_id set to systemId on a read-only connection.
  • withTenantTransaction(systemId, fn) — same, inside a transaction.

Bare db.execute(...) or db.transaction(...) outside the wrapper helpers (and the cross-account-*.ts helpers, which are the explicit exception for cross-account flows) is an ESLint error. The rule is configured in apps/api/eslint.config.js. The integration test rls-unset-context.integration.test.ts locks in the fail-silent behavior as a regression trap: un-contexted queries return [] rather than rows. A missed wrapper is a privacy bug that returns empty results rather than the wrong system's data, but a privacy bug nonetheless.

Typed auth context

Every protected route group must type its Hono instance with AuthEnv:

import { Hono } from "hono";

import type { AuthEnv } from "../../lib/auth-context.js";

export const myRoute = new Hono<AuthEnv>();

myRoute.get("/", (c) => {
  const auth = c.get("auth"); // typed as AuthContext, no assertion needed
  // ...
});

The parent mount must attach authMiddleware() via .use() before the typed sub-app is .route()-mounted; the middleware is responsible for calling c.set("auth", session) with the resolved AuthContext. Removing authMiddleware() from a typed sub-app's mount chain surfaces as a compile error at any downstream handler that reads auth, because c.set guarantees the variable is present.

Public (unauthenticated) routesauth/login, auth/register, auth/salt, auth/password-reset, i18n, and the top-level v1 mount — use bare new Hono() and must NOT call c.get("auth"). Add auth mid-path only if the route is moved behind authentication.

Linting

Zero warnings are tolerated. All ESLint warnings are treated as errors in CI, git hooks, and local scripts (--max-warnings 0). If a rule is too noisy, discuss changing its severity. Do not leave warnings in the codebase.

Test Coverage Requirements

Test coverage is enforced in CI. The thresholds below are minimums; aim higher where practical.

Test Type Coverage Target Tool What It Covers
Unit 89% lines/functions/branches Vitest Pure functions, utilities, domain logic
Integration 89% lines/functions/branches Vitest API routes, database queries, cross-module flows
E2E Critical paths Playwright User-facing flows: auth, fronting, switching, sync
  • Unit + Integration (89% combined): Lines, functions, branches, and statements are all measured. Type-only files and barrel/index files are excluded from coverage. Measured across the combined unit + integration run.
  • E2E tests: No line-coverage metric. All critical user journeys must have corresponding tests. Tracked via a test matrix in the test plan.

Coverage is checked in CI on every PR. PRs that drop coverage below thresholds will not merge.

Accessibility

  • All interactive elements must have accessibility props (accessibilityLabel, accessibilityRole, etc.)
  • Color must not be the only means of conveying information
  • Touch targets must meet minimum size (44x44pt)
  • Test with screen readers (VoiceOver on iOS, TalkBack on Android)

Translations

Pluralscape uses Crowdin for translation management. The project qualifies for the free open-source tier. The automation design is authoritative in ADR 036 — Crowdin automation; operational runbook lives in docs/i18n/crowdin-operations.md.

How translations flow

  1. English sources (apps/mobile/locales/en/**/*.json) are the source of truth.
  2. On merge to main, a GitHub Action uploads changed source strings to Crowdin.
  3. Translators work in Crowdin's web UI. Strings carry community-terminology notes (plural-community affirming language — never clinical).
  4. Every Monday at 06:00 UTC, a scheduled Action pulls approved translations and opens a PR titled chore(i18n): weekly Crowdin translation sync. Maintainers review the diff and merge.

Proposing a new locale

  1. Open a bean tracking the locale request.
  2. Add the locale tag to SUPPORTED_LOCALES in packages/i18n/src/i18n.constants.ts and BUNDLED_LOCALES in apps/mobile/locales/index.ts.
  3. Update crowdin.yml languages_mapping if the Crowdin locale tag differs from the Pluralscape tag.
  4. Add an empty baseline directory apps/mobile/locales/<locale>/ with empty namespace JSON stubs; Crowdin will populate them.
  5. Verify with pnpm vitest run --project i18n.

Runtime delivery

Apps ship with bundled baseline translations for offline-first behavior. Translation fixes reach users via an API-proxied OTA overlay (GET /v1/i18n/:locale/:namespace) without requiring an app release. See docs/adr/035-i18n-ota-delivery.md for details.

Feature Requests and Voting

Feature prioritization is community-driven. If you have a feature idea:

  1. Check existing Discussions and issues to avoid duplicates
  2. Open a thread in the Feature Requests discussion category
  3. Community upvotes help prioritize what gets built next

Reporting Issues

  • Bugs: Open a GitHub issue with steps to reproduce
  • Security vulnerabilities: See SECURITY.md — do not open a public issue

Code of Conduct

All contributors must follow our Code of Conduct. We have zero tolerance for bigotry, harassment, or gatekeeping.

Adding user-facing strings

English sources live in apps/mobile/locales/en/*.json. To add or modify a user-facing string:

  1. Add the key + English value to the appropriate file (auth.json, common.json, fronting.json, members.json, settings.json).
  2. Reference the key from the mobile UI via the i18n hook.
  3. Commit and open a PR as usual.

Translations are handled automatically:

  • On merge to main, the crowdin-sync workflow uploads the new source to Crowdin and triggers machine-translation (TM + MT with glossary enforcement) for all 12 target languages.
  • The next daily sync (06:00 UTC) opens a translation PR that auto-merges once CI passes.

If you're adding domain terminology (plurality, fronting, member roles, origins, etc.), check scripts/crowdin-glossary.json first — the term may already be defined. If not, consider adding it in the same PR.

See docs/i18n/crowdin-operations.md for operational details.